En las unidades anteriores trabajamos las metodologías (CRISP-DM, KDD, SEMMA, TDSP). En esta unidad vamos a recorrer las fases de CRISP-DM de punta a punta con un problema real del sector asegurador:
¿Cuánto le debe cobrar una aseguradora a cada asegurado de su cartera de autos?
Para responder vamos a usar una herramienta nueva: el Modelo Lineal Generalizado, que en todo el documento llamaremos GLM (por su sigla en inglés, Generalized Linear Model). No se preocupen por el nombre: es una “versión mejorada” de la regresión lineal que ya conocen, pensada para datos que no se comportan como una campana normal.
Vocabulario mínimo de seguros (lo usaremos todo el tiempo)
Cómo leer este documento. Las cajas grises que dicen “Para los curiosos” se pueden abrir con un clic. Tienen las fórmulas y los detalles técnicos. No son necesarias para entender el caso: si es su primer encuentro con estos temas, pueden saltarlas y volver después. Al final hay un glosario con todos los términos nuevos.
La ruta del documento es:
| Parte | Contenido | Fase de la unidad |
|---|---|---|
| 2 | Del modelo lineal al GLM: la idea y las condiciones para usarlo | Fundamentos |
| 3 | Dónde usar cada GLM: el mapa actuarial | Fundamentos |
| 4 | Entendimiento del negocio y los datos | III.1 |
| 5 | Entender los datos | III.2 |
| 6 | Preparación de los datos | III.3 |
| 7 | Modelamiento: frecuencia, severidad y prima pura | III.4 |
| 8 | Evaluación, implementación y monitoreo | III.5 |
| 9–10 | Síntesis y glosario | — |
# Si no los tienen instalados, quiten el # de las dos líneas siguientes y ejecútenlas una vez:
# install.packages(c("insuranceData", "dplyr", "ggplot2", "tidyr", "MASS", "statmod",
# "car", "kableExtra", "scales", "forcats", "gridExtra"))
library(insuranceData) # la base de datos dataCar
library(dplyr) # manipular datos
library(tidyr) # reorganizar tablas
library(ggplot2) # gráficos
library(MASS) # modelo binomial negativo
library(statmod) # residuales para diagnóstico y modelo Tweedie
library(car) # revisar si las variables se repiten información (VIF)
library(kableExtra) # tablas bonitas
library(scales) # formatos de números y porcentajes
select <- dplyr::select # MASS también tiene una función select(); usamos la de dplyr
theme_set(theme_minimal(base_size = 12))
tabla <- function(x, ...) {
kbl(x, ...) |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
}En la regresión lineal explicamos una variable \(Y\) con una recta (o un plano, si hay varias variables):
\[ Y = \beta_0 + \beta_1 x_1 + \dots + \beta_p x_p + \text{error} \]
Detrás de esa ecuación hay tres condiciones que casi nunca decimos en voz alta:
Con datos de seguros, ninguna de las tres se cumple: el número de siniestros es un conteo (0, 1, 2…), nunca es negativo, la mayoría de clientes tiene cero y los costos tienen unos pocos valores enormes.
Simulemos el número de siniestros de 300 conductores según sus años de experiencia. Los conductores con menos experiencia tienen más siniestros.
set.seed(123)
n <- 300
experiencia <- runif(n, 0, 30) # años de experiencia
siniestros <- rpois(n, lambda = exp(1.2 - 0.12 * experiencia)) # conteos simulados
sim <- data.frame(experiencia, siniestros)
m_lm <- lm(siniestros ~ experiencia, data = sim) # recta
m_glm <- glm(siniestros ~ experiencia, family = poisson(link = "log"), data = sim) # GLM
grid <- data.frame(experiencia = seq(0, 40, by = 0.5))
grid$`Modelo lineal` <- predict(m_lm, grid)
grid$`GLM Poisson` <- predict(m_glm, grid, type = "response")
grid |>
pivot_longer(-experiencia, names_to = "modelo", values_to = "pred") |>
ggplot(aes(experiencia, pred, color = modelo)) +
geom_jitter(data = sim, aes(experiencia, siniestros), inherit.aes = FALSE,
height = 0.1, alpha = 0.35) +
geom_line(linewidth = 1.2) +
geom_hline(yintercept = 0, linetype = "dashed") +
labs(x = "Años de experiencia", y = "Número de siniestros",
color = NULL, title = "La recta predice siniestros negativos") +
theme(legend.position = "top")## [1] -1.468 2.356
La recta falla en tres cosas: predice un número de siniestros negativo para los conductores con mucha experiencia (¡imposible!), supone que todos se dispersan igual (pero los inexpertos varían mucho más) y obliga a una línea recta, cuando lo que se ve en los datos es una curva que baja rápido y luego se aplana. El GLM (la curva roja) resuelve las tres.
Un GLM es una regresión a la que le permitimos (1) escoger la distribución que se parece a nuestros datos y (2) trabajar en una escala distinta, para que sus predicciones siempre tengan sentido.
Para armar un GLM se toman tres decisiones:
Decisión 1. ¿Qué forma tienen los datos? (la distribución) Si \(Y\) es un conteo, usamos la distribución de Poisson; si es un monto de dinero siempre positivo y con valores muy grandes ocasionales, la Gamma; si es un sí/no, la Binomial. Es elegir el “molde” que mejor encaja con los datos.
Decisión 2. ¿Qué variables explican \(Y\)? (la parte lineal) Esto es igual que en la regresión de siempre: una suma de variables multiplicadas por coeficientes.
\[ \text{puntaje} = \beta_0 + \beta_1 x_1 + \dots + \beta_p x_p \]
Decisión 3. ¿Cómo traducimos ese puntaje a la escala de \(Y\)? (la función de enlace) El puntaje puede ser cualquier número, incluso negativo. Pero un número de siniestros no puede ser negativo. El enlace es un traductor. El más usado en seguros es el logaritmo: el modelo trabaja con el puntaje y, para predecir, aplica la exponencial, que siempre da un número positivo.
puntajes <- c(-3, -1.5, 0, 1)
data.frame(puntaje = puntajes, `prediccion = exp(puntaje)` = exp(puntajes), check.names = FALSE) |>
tabla(digits = 3, caption = "El enlace logarítmico convierte cualquier puntaje en un valor positivo")| puntaje | prediccion = exp(puntaje) |
|---|---|
| -3.0 | 0.050 |
| -1.5 | 0.223 |
| 0.0 | 1.000 |
| 1.0 | 2.718 |
Idea clave: la regresión lineal que ya conocen es un GLM: el que usa la distribución normal y no traduce nada (enlace “identidad”). Todo lo que aprendieron de regresión sigue sirviendo. El GLM solo agrega flexibilidad para los datos que no son “normales”.
| Distribución | Tipo de Y | Ejemplo en seguros | ¿Cómo crece la variabilidad? | Enlace usual |
|---|---|---|---|---|
| Normal | Cualquier número | Pocas veces aplica | No crece: es constante | Identidad |
| Binomial | Sí / no (0 o 1) | ¿Tuvo siniestro? | Máxima cuando p = 0,5 | Logit |
| Poisson | Conteo: 0, 1, 2, … | Número de siniestros | Al ritmo de la media | Logaritmo |
| Gamma | Monto positivo y asimétrico | Costo de un siniestro | Más rápido que la media | Logaritmo |
| Tweedie | Muchos ceros + montos positivos | Costo anual de una póliza | Entre Poisson y Gamma | Logaritmo |
Todas las distribuciones de la tabla pertenecen a la familia exponencial, cuya densidad se puede escribir como
\[ f(y;\theta,\phi) = \exp\left\{\frac{y\theta - b(\theta)}{\phi} + c(y,\phi)\right\}. \]
De esa forma se deduce que \(E(Y)=\mu=b'(\theta)\) y \(\operatorname{Var}(Y)=\phi\,V(\mu)\). La función de varianza \(V(\mu)\) es la “huella digital” de cada distribución: Normal \(V=1\); Poisson \(V=\mu\); Gamma \(V=\mu^2\); Gaussiana inversa \(V=\mu^3\); Tweedie \(V=\mu^p\) con \(1<p<2\); Binomial \(V=\mu(1-\mu)\).
Con notación formal, el GLM se escribe como \(g(\mu_i) = \eta_i = \beta_0 + \sum_j \beta_j x_{ij}\), donde \(\eta\) es el predictor lineal (el “puntaje”) y \(g\) es la función de enlace. El modelo fue propuesto por Nelder y Wedderburn (1972).En la regresión lineal usamos mínimos cuadrados: buscar la recta que deja los errores al cuadrado más pequeños. En un GLM la idea cambia un poco y se llama máxima verosimilitud. Suena complicado, pero la pregunta es sencilla:
¿Qué valores de los coeficientes hacen que los datos que observamos sean lo más “creíbles” posible?
Veámoslo con un ejemplo. En la simulación anterior, supongamos que todos los conductores tuvieran la misma frecuencia \(\lambda\). Probemos muchos valores de \(\lambda\) y calculemos qué tan probable sería observar nuestros datos con cada uno:
lambdas <- seq(0.3, 2.5, by = 0.01)
log_veros <- sapply(lambdas, function(l) sum(dpois(sim$siniestros, lambda = l, log = TRUE)))
mejor <- lambdas[which.max(log_veros)]
ggplot(data.frame(lambdas, log_veros), aes(lambdas, log_veros)) +
geom_line(linewidth = 1, color = "#2c7fb8") +
geom_vline(xintercept = mejor, linetype = "dashed", color = "#e6550d") +
annotate("text", x = mejor + 0.05, y = min(log_veros) + 50, hjust = 0,
label = paste0("Mejor valor: ", mejor), color = "#e6550d") +
labs(x = "Valor propuesto para la frecuencia λ",
y = "Qué tan creíbles son los datos\n(log-verosimilitud)",
title = "Máxima verosimilitud: buscar la cima de la curva")## λ que maximiza Promedio de siniestros
## 0.9200 0.9233
El valor que hace “más creíbles” los datos prácticamente coincide con
el promedio de siniestros (la pequeña diferencia es porque probamos
valores de 0,01 en 0,01), que es lo que la intuición diría. En un GLM, R
hace exactamente esto, pero con todos los coeficientes a la vez. No hay
que hacerlo a mano: la función glm() lo resuelve.
Para comparar modelos usaremos dos indicadores. Basta con saber cómo leerlos:
La devianza se define como \(D = 2\,\phi\,[\ell(\text{modelo saturado}) - \ell(\text{modelo ajustado})]\), donde \(\ell\) es la log-verosimilitud y el modelo saturado es uno con un parámetro por observación (ajuste perfecto). La diferencia de devianzas entre dos modelos anidados da la prueba de razón de verosimilitud. El AIC es \(-2\ell + 2k\), con \(k\) el número de parámetros.
R maximiza la verosimilitud con el algoritmo IRLS (mínimos cuadrados ponderados iterativos): en cada paso resuelve una regresión ponderada y repite hasta que los coeficientes dejan de cambiar.Con el enlace logarítmico, la predicción es
\[ \text{predicción} = e^{\beta_0}\times e^{\beta_1 x_1}\times\cdots\times e^{\beta_p x_p}. \]
Es decir, el efecto de cada variable multiplica en lugar de sumar. Por eso no leemos \(\beta\) directamente, sino \(e^{\beta}\), al que llamaremos factor:
Ejemplo: si la frecuencia base es 0,15 siniestros al año, un conductor joven tiene factor 1,30 y su vehículo es costoso con factor 1,20, su frecuencia esperada es \(0{,}15 \times 1{,}30 \times 1{,}20 = 0{,}234\).
Así se construyen las tarifas de seguros en el mundo real: una tarifa base multiplicada por recargos y descuentos. Esta es la razón principal por la que los GLM son el estándar de la industria.
Antes de confiar en un modelo, hay que revisar que se cumplan ciertas condiciones. La tabla las presenta como preguntas que debemos hacernos.
| Supuesto | La pregunta que hay que hacerse | Cómo lo revisamos |
|---|---|---|
|
¿El molde (Poisson, Gamma…) se parece a la forma de mis datos? | Gráficos de la variable: ¿cuántos ceros?, ¿cola larga? |
|
¿La variabilidad de los datos crece con la media como lo supone la distribución elegida? | Gráfico de media contra varianza; indicador de dispersión cercano a 1 |
|
Después de aplicar el logaritmo, ¿el efecto de cada variable es parecido a una recta? | Gráfico de la frecuencia (en logaritmo) por grupos de la variable |
|
¿Cada póliza es un caso distinto, o hay clientes repetidos que se influyen? | Conocer cómo se construyó la base |
|
¿Tuve en cuenta que unas pólizas estuvieron vigentes más tiempo que otras? | Incluir la exposición en el modelo (offset) |
|
¿Hay dos variables que dicen prácticamente lo mismo? | Indicador VIF |
|
¿Hay unos pocos registros que, si los quito, cambian todo el modelo? | Gráfico de residuales |
Lo que cambia frente a la regresión lineal: ya no necesitamos que los datos sean normales ni que todos tengan la misma dispersión. Lo que se vuelve importante es elegir bien la distribución, para que el modelo describa correctamente cuánto varían los datos. Si nos equivocamos, el modelo puede acertar en los promedios, pero se equivoca en cuán seguro está de sus resultados, y entonces los p-valores dejan de ser confiables.
La primera pregunta siempre es: ¿qué tipo de variable quiero predecir? La respuesta casi decide el modelo.
| Pregunta de negocio | Tipo de Y | Modelo | Resultado |
|---|---|---|---|
| ¿Esta póliza tendrá al menos un siniestro? | Sí / no | Binomial (logit) | Probabilidad de siniestro |
| ¿El cliente cancelará su póliza? | Sí / no | Binomial (logit) | Tasa de cancelación |
| ¿Qué probabilidad hay de que una persona fallezca este año? | Sí / no | Binomial (logit) | Tabla de mortalidad |
| ¿Cuántos siniestros tendrá un cliente por año? | Conteo | Poisson o Binomial negativa (log) | Frecuencia |
| ¿Cuántas muertes habrá en un grupo de edad? | Conteo | Poisson (log) | Tasa de mortalidad |
| ¿Cuánto cuesta un siniestro? | Monto positivo con cola larga | Gamma (log) | Severidad (costo promedio) |
| ¿Cuánto cuesta una póliza en el año (incluyendo los que no tienen siniestros)? | Muchos ceros + montos | Tweedie (log) | Prima pura |
En seguros de autos, la forma clásica de calcular lo que se debe cobrar separa el riesgo en dos piezas:
\[ \text{Prima pura} = \underbrace{\text{Frecuencia}}_{\text{¿cuántas veces?}} \times \underbrace{\text{Severidad}}_{\text{¿cuánto cuesta cada vez?}} \]
Así entendemos por qué un cliente es costoso: porque se accidenta más, porque sus accidentes son más caros, o por las dos cosas. Esta separación supone que el número de siniestros y su costo no dependen uno del otro, que es el supuesto habitual en los modelos actuariales.
Una aseguradora de autos quiere revisar su tarifa. Hoy cobra lo mismo a casi todos, y sospecha que:
Objetivo de negocio: cobrar a cada asegurado una prima acorde con su riesgo.
Objetivo analítico: estimar la prima pura anual (costo esperado de siniestros por año de exposición) para cada perfil de asegurado y, a partir de ella, la prima comercial.
Criterio de éxito: que, en datos que el modelo no ha visto, la prima pura total sea cercana a los siniestros reales (cociente real/esperado ≈ 1) y que el modelo ordene bien el riesgo: los asegurados que el modelo considera más caros deben ser, de verdad, los más costosos.
Usamos dataCar del paquete insuranceData:
pólizas de seguro de autos de un año (2004–2005), con 67 856
registros.
## Rows: 67,856
## Columns: 11
## $ veh_value <dbl> 1.06, 1.03, 3.26, 4.14, 0.72, 2.01, 1.60, 1.47, 0.52, 0.38, …
## $ exposure <dbl> 0.30390, 0.64887, 0.56947, 0.31759, 0.64887, 0.85421, 0.8542…
## $ clm <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, …
## $ numclaims <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, …
## $ claimcst0 <dbl> 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, …
## $ veh_body <fct> HBACK, HBACK, UTE, STNWG, HBACK, HDTOP, PANVN, HBACK, HBACK,…
## $ veh_age <int> 3, 2, 2, 2, 4, 3, 3, 2, 4, 4, 2, 3, 2, 1, 3, 2, 3, 3, 4, 3, …
## $ gender <fct> F, F, F, F, F, M, M, M, F, F, M, M, F, M, M, M, F, M, F, F, …
## $ area <fct> C, A, E, D, C, C, A, B, A, B, A, C, C, A, B, C, F, C, D, C, …
## $ agecat <int> 2, 4, 2, 2, 2, 4, 4, 6, 3, 4, 2, 4, 4, 5, 6, 4, 4, 4, 2, 3, …
## $ X_OBSTAT_ <fct> 01101 0 0 0, 01101 0 0 0, 01101 0 0 …
| Variable | Descripción | Rol en el modelo |
|---|---|---|
| veh_value | Valor del vehículo (en decenas de miles) | Predictor |
| exposure | Fracción del año que la póliza estuvo vigente (0 a 1) | Exposición (offset) |
| clm | Indicador de al menos un siniestro (0/1) | Respuesta binaria |
| numclaims | Número de siniestros | Respuesta de frecuencia |
| claimcst0 | Costo total de los siniestros | Respuesta de severidad |
| veh_body | Tipo de carrocería | Predictor |
| veh_age | Edad del vehículo (1 = más nuevo, 4 = más viejo) | Predictor |
| gender | Sexo del conductor | Predictor (¿ético?) |
| area | Zona de residencia (A a F) | Predictor |
| agecat | Categoría de edad del conductor (1 = más joven, 6 = mayor) | Predictor |
| X_OBSTAT_ | Variable técnica sin información | Se descarta |
Esta fase decide la familia del GLM. Antes de modelar, miramos la forma de cada respuesta.
dataCar |>
count(numclaims) |>
mutate(porcentaje = percent(n / sum(n), accuracy = 0.01)) |>
tabla(caption = "Distribución del número de siniestros")| numclaims | n | porcentaje |
|---|---|---|
| 0 | 63232 | 93.19% |
| 1 | 4333 | 6.39% |
| 2 | 271 | 0.40% |
| 3 | 18 | 0.03% |
| 4 | 2 | 0.00% |
Más del 93 % de las pólizas no tuvo siniestros. La respuesta es un conteo con media baja: candidata a Poisson.
ggplot(dataCar, aes(exposure)) +
geom_histogram(bins = 40, fill = "#2c7fb8", color = "white") +
labs(x = "Exposición (fracción del año)", y = "Pólizas",
title = "La exposición varía de unos días a un año completo")Una póliza vigente 3 meses tiene menos oportunidad de tener un siniestro que una vigente 12 meses. Si no lo tenemos en cuenta, vamos a creer que los clientes de pólizas cortas son “más cuidadosos”, cuando en realidad solo estuvieron menos tiempo en la calle. Por eso no modelamos “siniestros por póliza”, sino siniestros por año de exposición. Más adelante veremos cómo se le dice esto al modelo con algo llamado offset.
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 200 354 713 1916 1952 55922
ggplot(sev_obs, aes(sev)) +
geom_histogram(bins = 50, fill = "#e6550d", color = "white") +
scale_x_continuous(labels = comma) +
labs(x = "Costo medio por siniestro", y = "Siniestros",
title = "La severidad tiene una cola derecha muy larga")La media es mucho mayor que la mediana: unos pocos siniestros muy costosos “jalan” el promedio hacia arriba. El histograma no se parece en nada a una campana normal; tiene una cola larga a la derecha. Para montos así, el molde candidato es la Gamma.
Pero hay algo más. Miremos los valores más frecuentes:
sev_obs |> count(claimcst0, sort = TRUE) |> head(5) |>
mutate(porcentaje = percent(n / nrow(sev_obs), accuracy = 0.1)) |>
tabla(caption = "Los cinco costos más repetidos")| claimcst0 | n | porcentaje |
|---|---|---|
| 200.0 | 695 | 15.0% |
| 353.8 | 219 | 4.7% |
| 389.9 | 94 | 2.0% |
| 390.0 | 55 | 1.2% |
| 367.7 | 42 | 0.9% |
Un hallazgo para llevarle al negocio: alrededor del 15 % de los siniestros cuesta exactamente 200, y ninguno cuesta menos. Eso no es azar: puede ser un valor mínimo de registro, un costo fijo de atención o un efecto del deducible. Un analista no lo “limpia” por su cuenta; pregunta. Y, como veremos, este detalle afectará el ajuste del modelo de severidad.
frec_empirica <- function(var) {
dataCar |>
group_by(nivel = factor(.data[[var]])) |>
summarise(polizas = n(), frecuencia = sum(numclaims) / sum(exposure), .groups = "drop") |>
mutate(variable = var)
}
bind_rows(lapply(c("agecat", "veh_age", "area", "gender"), frec_empirica)) |>
ggplot(aes(nivel, frecuencia)) +
geom_col(fill = "#2c7fb8") +
facet_wrap(~variable, scales = "free_x") +
labs(x = NULL, y = "Siniestros por año de exposición",
title = "Frecuencia observada por variable")El patrón más claro: los conductores más jóvenes (agecat = 1) tienen mayor frecuencia.
autos <- dataCar |>
filter(veh_value > 0) |> # vehículos sin valor registrado
select(-X_OBSTAT_) |>
mutate(
agecat = factor(agecat),
veh_age = factor(veh_age),
area = factor(area),
gender = factor(gender),
# carrocerías con pocos datos se agrupan para estimar factores estables
veh_body = forcats::fct_lump_min(veh_body, min = 1000, other_level = "OTRO"),
veh_body = relevel(veh_body, ref = "SEDAN"),
valor_cat = cut(veh_value, breaks = c(0, 1, 1.5, 2, 2.5, Inf), right = FALSE,
labels = c("<1", "1-1.5", "1.5-2", "2-2.5", "2.5+"))
)
nrow(dataCar) - nrow(autos) # registros eliminados## [1] 53
##
## SEDAN HBACK HDTOP STNWG TRUCK UTE OTRO
## 22232 18915 1579 16234 1742 4586 2515
¿Por qué convertir el valor del vehículo en tramos? El supuesto 3 pide que, después de aplicar el logaritmo, el efecto de la variable se parezca a una recta. El gráfico siguiente muestra que no es así: la frecuencia sube rápido para los vehículos baratos y luego se estabiliza. Una solución práctica, y muy común en seguros, es agrupar en tramos (menos de 1, de 1 a 1,5, etc.) y dejar que el modelo estime un factor para cada tramo.
autos |>
mutate(tramo = ntile(veh_value, 20)) |>
group_by(tramo) |>
summarise(valor = median(veh_value), log_frec = log(sum(numclaims) / sum(exposure))) |>
ggplot(aes(valor, log_frec)) +
geom_point(size = 2.5, color = "#2c7fb8") +
geom_smooth(method = "loess", se = FALSE, color = "#e6550d") +
labs(x = "Valor del vehículo (mediana del tramo)", y = "log(frecuencia)",
title = "Supuesto 3: la relación en escala log no es una recta")set.seed(2026)
id_train <- sample(nrow(autos), size = 0.8 * nrow(autos))
train <- autos[id_train, ]
test <- autos[-id_train, ]
# Severidad: solo pólizas con siniestro; costo medio por siniestro
train_sev <- train |> filter(clm == 1) |> mutate(sev = claimcst0 / numclaims)
test_sev <- test |> filter(clm == 1) |> mutate(sev = claimcst0 / numclaims)
data.frame(Base = c("Frecuencia (train)", "Frecuencia (test)", "Severidad (train)", "Severidad (test)"),
Registros = c(nrow(train), nrow(test), nrow(train_sev), nrow(test_sev))) |>
tabla()| Base | Registros |
|---|---|
| Frecuencia (train) | 54242 |
| Frecuencia (test) | 13561 |
| Severidad (train) | 3697 |
| Severidad (test) | 921 |
Separamos los datos en dos partes:
train, 80 %): con estos
datos el modelo “estudia”.test, 20 %): se guardan y
no se tocan hasta la evaluación. Funcionan como un
examen con preguntas que el modelo nunca vio. Si le va bien ahí, podemos
confiar en que funcionará con clientes nuevos.Además, la severidad solo se puede estudiar en las pólizas que sí tuvieron siniestro, por eso creamos una base aparte para ella.
Cada distribución tiene su propia “regla” sobre cómo crece la variabilidad cuando crece el promedio. En la Poisson, la varianza es igual a la media. En la Gamma, la varianza crece con el cuadrado de la media (mucho más rápido).
Para ver cuál regla siguen nuestros datos, formamos grupos de pólizas parecidas (misma edad, mismo tramo de valor, misma zona), calculamos la media y la varianza de cada grupo y las graficamos en escala logarítmica. En esa escala, la regla aparece como la pendiente de los puntos: pendiente 1 indica Poisson y pendiente 2 indica Gamma.
celdas_frec <- train |>
group_by(agecat, valor_cat, area) |>
summarise(media = mean(numclaims), varianza = var(numclaims), n = n(), .groups = "drop") |>
filter(n >= 100)
celdas_sev <- train_sev |>
group_by(agecat, area) |>
summarise(media = mean(sev), varianza = var(sev), n = n(), .groups = "drop") |>
filter(n >= 30)
p1 <- ggplot(celdas_frec, aes(log(media), log(varianza))) +
geom_point(color = "#2c7fb8") +
geom_abline(slope = 1, intercept = 0, linetype = "dashed") +
labs(title = "Frecuencia", subtitle = "Línea: Var = μ (Poisson)")
ajuste_sev <- coef(lm(log(varianza) ~ log(media), data = celdas_sev))
p2 <- ggplot(celdas_sev, aes(log(media), log(varianza))) +
geom_point(color = "#e6550d") +
geom_smooth(method = "lm", se = FALSE, color = "grey40") +
labs(title = "Severidad",
subtitle = paste0("Pendiente estimada: ", round(ajuste_sev[2], 2), " (Gamma ⇒ 2)"))
gridExtra_ok <- requireNamespace("gridExtra", quietly = TRUE)
if (gridExtra_ok) gridExtra::grid.arrange(p1, p2, ncol = 2) else { print(p1); print(p2) }Queremos predecir el número de siniestros con un modelo Poisson y enlace logarítmico. Pero hay que decirle al modelo cuánto tiempo estuvo vigente cada póliza. Eso se hace con un offset: una variable que entra al modelo sin coeficiente que estimar, solo para ajustar la escala. En la práctica, con el offset el modelo predice siniestros por año y luego los multiplica por el tiempo que la póliza estuvo vigente.
En R se escribe offset(log(exposure)). Usamos el
logaritmo porque el modelo trabaja en escala logarítmica.
\[ \log\left(E[N_i]\right) = \log(\text{exposición}_i) + \beta_0 + \sum_j \beta_j x_{ij} \;\;\Longrightarrow\;\; \frac{E[N_i]}{\text{exposición}_i} = e^{\beta_0 + \sum_j \beta_j x_{ij}} \]
El término \(\log(\text{exposición})\) entra con coeficiente fijo igual a 1.Empezamos con todas las variables candidatas y usamos
drop1(): esta función quita una variable a la
vez y mide cuánto empeora el modelo. Si el p-valor
(Pr(>Chi)) es pequeño (menor a 0,05), la variable
sí aporta y conviene dejarla.
frec_completo <- glm(
numclaims ~ agecat + area + veh_age + veh_body + valor_cat + gender + offset(log(exposure)),
family = poisson(link = "log"), data = train
)
drop1(frec_completo, test = "Chisq")La edad del conductor (agecat) aporta muchísimo; el tipo
de carrocería (veh_body) y el valor del vehículo
(valor_cat) también. La zona, la edad del vehículo y el
sexo no muestran un aporte claro en la frecuencia. Nos quedamos con un
modelo más sencillo, con las tres variables que sí aportan:
frec_pois <- glm(
numclaims ~ agecat + veh_body + valor_cat + offset(log(exposure)),
family = poisson(link = "log"), data = train
)
summary(frec_pois)##
## Call:
## glm(formula = numclaims ~ agecat + veh_body + valor_cat + offset(log(exposure)),
## family = poisson(link = "log"), data = train)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -1.71930 0.06406 -26.84 < 0.0000000000000002 ***
## agecat2 -0.16701 0.06088 -2.74 0.00608 **
## agecat3 -0.21731 0.05928 -3.67 0.00025 ***
## agecat4 -0.25341 0.05924 -4.28 0.000018893182380 ***
## agecat5 -0.49752 0.06646 -7.49 0.000000000000071 ***
## agecat6 -0.49581 0.07651 -6.48 0.000000000091503 ***
## veh_bodyHBACK -0.00511 0.04219 -0.12 0.90370
## veh_bodyHDTOP -0.10160 0.10685 -0.95 0.34165
## veh_bodySTNWG -0.08044 0.04809 -1.67 0.09438 .
## veh_bodyTRUCK -0.12793 0.10356 -1.24 0.21671
## veh_bodyUTE -0.22529 0.07316 -3.08 0.00207 **
## veh_bodyOTRO 0.16559 0.08052 2.06 0.03973 *
## valor_cat1-1.5 0.11744 0.04727 2.48 0.01298 *
## valor_cat1.5-2 0.14277 0.04936 2.89 0.00382 **
## valor_cat2-2.5 0.26156 0.06138 4.26 0.000020334181131 ***
## valor_cat2.5+ 0.29570 0.05671 5.21 0.000000184340371 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for poisson family taken to be 1)
##
## Null deviance: 20315 on 54241 degrees of freedom
## Residual deviance: 20183 on 54226 degrees of freedom
## AIC: 27753
##
## Number of Fisher Scoring iterations: 6
La Poisson tiene una regla estricta: la varianza debe ser igual a la media. Cuando los datos varían más de lo que permite esa regla, decimos que hay sobredispersión. Pasa mucho en seguros, porque hay clientes que, por razones que no vemos en los datos, son más propensos a accidentarse.
Para medirla usamos un indicador de dispersión: compara la variabilidad real con la que espera el modelo. Si el Poisson está bien, debe dar cerca de 1; si da bastante más de 1, hay sobredispersión.
dispersion_pearson <- sum(residuals(frec_pois, type = "pearson")^2) / df.residual(frec_pois)
dispersion_devianza <- deviance(frec_pois) / df.residual(frec_pois)
c(Pearson = dispersion_pearson, Devianza = dispersion_devianza)## Pearson Devianza
## 1.3839 0.3722
Hay dos formas de calcular el indicador y aquí dan respuestas distintas. La versión basada en la devianza sale muy por debajo de 1 y parece decir que “todo está bien”, pero con tan pocos siniestros por póliza esa versión no es confiable. La versión de Pearson, que es la que hay que mirar en estos casos, está por encima de 1: hay sobredispersión moderada. Consecuencia práctica: el Poisson está demasiado seguro de sí mismo y algunas variables pueden parecer importantes sin serlo.
La binomial negativa también sirve para conteos,
pero es más flexible: permite que la varianza sea mayor
que la media. Es como un Poisson con un “margen extra” de variabilidad,
controlado por un parámetro llamado \(\theta\) (theta). En R se ajusta con
glm.nb().
Para compararlos usamos el AIC (más pequeño es mejor) y miramos el error estándar (EE) de cada coeficiente, que indica qué tan seguro está el modelo de ese valor: un EE pequeño significa mucha seguridad.
frec_nb <- glm.nb(
numclaims ~ agecat + veh_body + valor_cat + offset(log(exposure)),
data = train
)
data.frame(
Modelo = c("Poisson", "Binomial negativa"),
AIC = c(AIC(frec_pois), AIC(frec_nb)),
theta = c(NA, frec_nb$theta)
) |> tabla(digits = 2, caption = "Comparación de modelos de frecuencia")| Modelo | AIC | theta |
|---|---|---|
| Poisson | 27753 | NA |
| Binomial negativa | 27731 | 2.61 |
data.frame(
Coeficiente = names(coef(frec_pois)),
`Factor (Poisson)` = exp(coef(frec_pois)),
`EE Poisson` = sqrt(diag(vcov(frec_pois))),
`Factor (BN)` = exp(coef(frec_nb)),
`EE BN` = sqrt(diag(vcov(frec_nb))),
check.names = FALSE
) |> tabla(digits = 3, row.names = FALSE,
caption = "Factores y errores estándar (EE) en ambos modelos")| Coeficiente | Factor (Poisson) | EE Poisson | Factor (BN) | EE BN |
|---|---|---|---|---|
| (Intercept) | 0.179 | 0.064 | 0.180 | 0.065 |
| agecat2 | 0.846 | 0.061 | 0.844 | 0.062 |
| agecat3 | 0.805 | 0.059 | 0.804 | 0.061 |
| agecat4 | 0.776 | 0.059 | 0.775 | 0.060 |
| agecat5 | 0.608 | 0.066 | 0.606 | 0.068 |
| agecat6 | 0.609 | 0.077 | 0.607 | 0.078 |
| veh_bodyHBACK | 0.995 | 0.042 | 0.996 | 0.043 |
| veh_bodyHDTOP | 0.903 | 0.107 | 0.901 | 0.109 |
| veh_bodySTNWG | 0.923 | 0.048 | 0.923 | 0.049 |
| veh_bodyTRUCK | 0.880 | 0.104 | 0.879 | 0.106 |
| veh_bodyUTE | 0.798 | 0.073 | 0.798 | 0.074 |
| veh_bodyOTRO | 1.180 | 0.081 | 1.180 | 0.082 |
| valor_cat1-1.5 | 1.125 | 0.047 | 1.122 | 0.048 |
| valor_cat1.5-2 | 1.153 | 0.049 | 1.151 | 0.050 |
| valor_cat2-2.5 | 1.299 | 0.061 | 1.295 | 0.063 |
| valor_cat2.5+ | 1.344 | 0.057 | 1.342 | 0.058 |
Lección: los factores de tarifa son prácticamente iguales en los dos modelos. Lo que cambia la sobredispersión es qué tan seguros podemos estar de esos factores. En esta cartera la diferencia en los errores estándar es pequeña, porque hay muy pocos siniestros por póliza y el “margen extra” casi no se nota. Aun así, la binomial negativa tiene menor AIC, así que la usaremos para la tarifa. En carteras con muchos siniestros por cliente (salud, flotas de empresas) la diferencia sí es grande.
frec_sin_offset <- glm.nb(numclaims ~ agecat + veh_body + valor_cat + veh_age, data = train)
frec_con_offset <- glm.nb(numclaims ~ agecat + veh_body + valor_cat + veh_age + offset(log(exposure)),
data = train)
data.frame(
Término = c("Intercepto (frecuencia base)", "veh_age2", "veh_age3", "veh_age4"),
`Con offset` = exp(coef(frec_con_offset))[c("(Intercept)", "veh_age2", "veh_age3", "veh_age4")],
`Sin offset` = exp(coef(frec_sin_offset))[c("(Intercept)", "veh_age2", "veh_age3", "veh_age4")],
check.names = FALSE
) |> tabla(digits = 3, row.names = FALSE)| Término | Con offset | Sin offset |
|---|---|---|
| Intercepto (frecuencia base) | 0.180 | 0.069 |
| veh_age2 | 1.108 | 1.223 |
| veh_age3 | 0.990 | 1.126 |
| veh_age4 | 1.003 | 1.185 |
| veh_age | exposicion_media |
|---|---|
| 1 | 0.436 |
| 2 | 0.478 |
| 3 | 0.476 |
| 4 | 0.474 |
Olvidar el offset produce dos errores.
veh_age = 1) estuvieron
asegurados menos tiempo en promedio. Sin offset parecen más seguros de
lo que son, y a los vehículos viejos se les cobraría un recargo injusto.
Con el offset, ese supuesto efecto de la edad del vehículo prácticamente
desaparece.En regresión lineal revisábamos los residuales (lo que el modelo no logra explicar) para ver si el modelo estaba bien. Con conteos, los residuales comunes forman “rayas” que no se pueden leer. Por eso usamos los residuales cuantílicos: una transformación que convierte los residuales en valores que, si el modelo es correcto, se comportan como una normal.
Se leen con un gráfico QQ, como en regresión lineal: si los puntos caen sobre la recta, el modelo describe bien los datos; si se alejan, algo falla.
set.seed(1)
rq_pois <- qresiduals(frec_pois)
rq_nb <- qresiduals(frec_nb)
data.frame(Poisson = rq_pois, `Binomial negativa` = rq_nb, check.names = FALSE) |>
pivot_longer(everything(), names_to = "modelo", values_to = "residual") |>
ggplot(aes(sample = residual)) +
stat_qq(alpha = 0.3, size = 0.6, color = "#2c7fb8") +
stat_qq_line(color = "#e6550d") +
facet_wrap(~modelo) +
labs(title = "QQ-plot de residuales cuantílicos aleatorizados",
x = "Cuantiles teóricos N(0,1)", y = "Residual cuantílico")En los dos modelos los puntos siguen la recta casi a la perfección: el modelo de frecuencia describe bien los datos.
Si dos variables dicen prácticamente lo mismo (por ejemplo, el valor del vehículo y su precio de compra), el modelo no sabe a cuál atribuirle el efecto y los factores se vuelven inestables. El indicador VIF lo detecta.
## GVIF Df GVIF^(1/(2*Df))
## agecat 1.042 5 1.004
## veh_body 1.518 6 1.035
## valor_cat 1.474 4 1.050
Miramos la última columna: valores cercanos a 1 indican que no hay problema; valores mayores a 2 o 3 serían una señal de alerta. Aquí todo está bien.
Convertimos cada coeficiente en su factor (\(e^{\beta}\)) y calculamos un intervalo de confianza del 95 %: el rango donde razonablemente está el verdadero valor del factor. Si el intervalo no incluye el 1, la característica tiene un efecto claro.
factores_frec <- exp(cbind(Factor = coef(frec_nb), confint.default(frec_nb)))
factores_frec |> tabla(digits = 3, caption = "Factores multiplicativos de frecuencia (binomial negativa)")| Factor | 2.5 % | 97.5 % | |
|---|---|---|---|
| (Intercept) | 0.180 | 0.158 | 0.205 |
| agecat2 | 0.844 | 0.747 | 0.953 |
| agecat3 | 0.804 | 0.714 | 0.905 |
| agecat4 | 0.775 | 0.688 | 0.872 |
| agecat5 | 0.606 | 0.531 | 0.692 |
| agecat6 | 0.607 | 0.521 | 0.707 |
| veh_bodyHBACK | 0.996 | 0.916 | 1.084 |
| veh_bodyHDTOP | 0.901 | 0.728 | 1.116 |
| veh_bodySTNWG | 0.923 | 0.838 | 1.016 |
| veh_bodyTRUCK | 0.879 | 0.714 | 1.081 |
| veh_bodyUTE | 0.798 | 0.689 | 0.923 |
| veh_bodyOTRO | 1.180 | 1.004 | 1.387 |
| valor_cat1-1.5 | 1.122 | 1.021 | 1.233 |
| valor_cat1.5-2 | 1.151 | 1.043 | 1.270 |
| valor_cat2-2.5 | 1.295 | 1.145 | 1.464 |
| valor_cat2.5+ | 1.342 | 1.198 | 1.503 |
data.frame(termino = rownames(factores_frec), factores_frec, check.names = FALSE) |>
filter(termino != "(Intercept)") |>
ggplot(aes(x = Factor, y = reorder(termino, Factor))) +
geom_vline(xintercept = 1, linetype = "dashed") +
geom_errorbarh(aes(xmin = `2.5 %`, xmax = `97.5 %`), height = 0.2, color = "grey50") +
geom_point(size = 3, color = "#2c7fb8") +
labs(x = "Factor multiplicativo sobre la frecuencia", y = NULL,
title = "Factores con intervalo de confianza del 95 %",
subtitle = "A la derecha de 1: recargo · a la izquierda de 1: descuento")Lectura para la gerencia, por ejemplo: un conductor de la categoría de edad 6 tiene una frecuencia de siniestros 39 % menor que un conductor de la categoría 1 (la más joven), con las demás características iguales.
Ahora modelamos cuánto cuesta cada siniestro, usando
solo las pólizas que tuvieron al menos uno. Le damos más peso
(weights) a las pólizas con varios siniestros, porque una
póliza con 3 siniestros nos dice más sobre el costo típico que una con
1.
De nuevo usamos drop1() para ver qué variables aportan.
Aquí la prueba se llama F en lugar de Chi-cuadrado,
porque la Gamma tiene un parámetro extra de dispersión que también se
estima; la lectura es la misma: p-valor pequeño = la variable
aporta.
sev_completo <- glm(
sev ~ agecat + area + veh_age + veh_body + valor_cat + gender,
family = Gamma(link = "log"), weights = numclaims, data = train_sev
)
drop1(sev_completo, test = "F")En la severidad aportan con claridad la edad del conductor, la zona y el sexo. El tipo de carrocería tiene un aporte débil y, para mantener el modelo sencillo, lo dejamos por fuera. Curiosamente, son variables distintas de las que importaban en la frecuencia: lo que hace que alguien se accidente más no es lo mismo que hace que sus accidentes cuesten más.
sev_gamma <- glm(
sev ~ agecat + area + gender,
family = Gamma(link = "log"), weights = numclaims, data = train_sev
)
summary(sev_gamma)##
## Call:
## glm(formula = sev ~ agecat + area + gender, family = Gamma(link = "log"),
## data = train_sev, weights = numclaims)
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 7.6133 0.1044 72.90 <0.0000000000000002 ***
## agecat2 -0.1646 0.1083 -1.52 0.1288
## agecat3 -0.2504 0.1051 -2.38 0.0173 *
## agecat4 -0.2548 0.1053 -2.42 0.0156 *
## agecat5 -0.3503 0.1181 -2.97 0.0030 **
## agecat6 -0.2358 0.1358 -1.74 0.0826 .
## areaB -0.0288 0.0852 -0.34 0.7354
## areaC 0.0987 0.0779 1.27 0.2052
## areaD 0.0400 0.1054 0.38 0.7042
## areaE 0.1352 0.1128 1.20 0.2310
## areaF 0.3792 0.1305 2.90 0.0037 **
## genderM 0.1741 0.0577 3.02 0.0026 **
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for Gamma family taken to be 3.183)
##
## Null deviance: 6074.8 on 3696 degrees of freedom
## Residual deviance: 5967.2 on 3685 degrees of freedom
## AIC: 67118
##
## Number of Fisher Scoring iterations: 7
El parámetro de dispersión
(Dispersion parameter en la salida) indica qué tanto varían
los costos. Su raíz cuadrada es el coeficiente de
variación: 1.78. Eso significa que la desviación estándar del
costo es casi el doble de su promedio: los costos de los siniestros son
muy variables, como ya anticipaba el histograma.
Dos pistas ya nos advertían: la pendiente media–varianza de la severidad salió por encima de 2, y el 15 % de los costos está pegado en 200. Revisemos los residuales cuantílicos.
set.seed(1)
rq_gamma <- qresiduals(sev_gamma)
ggplot(data.frame(rq = rq_gamma), aes(sample = rq)) +
stat_qq(alpha = 0.4, color = "#e6550d") +
stat_qq_line() +
labs(title = "Severidad Gamma: residuales cuantílicos",
x = "Cuantiles teóricos N(0,1)", y = "Residual cuantílico")La Gamma no ajusta bien: los puntos se alejan de la recta en los dos extremos.
Esto es justo lo que pasa en la vida real: el primer modelo casi nunca es el último. El diagnóstico nos dice que debemos probar otra cosa.
Recordemos la pista: la pendiente media–varianza de la severidad era
mayor que 2, es decir, la variabilidad crece todavía más
rápido de lo que supone la Gamma. Existe otra distribución para
montos positivos que admite eso: la Gaussiana inversa.
Tiene una cola derecha más larga, así que acepta mejor los siniestros
muy costosos. En R basta con cambiar family = Gamma(...)
por family = inverse.gaussian(...).
sev_ig <- glm(
sev ~ agecat + area + gender,
family = inverse.gaussian(link = "log"), weights = numclaims, data = train_sev,
control = glm.control(maxit = 100)
)
set.seed(1)
rq_ig <- qresiduals(sev_ig)
data.frame(Gamma = rq_gamma, `Gaussiana inversa` = rq_ig, check.names = FALSE) |>
pivot_longer(everything(), names_to = "modelo", values_to = "residual") |>
ggplot(aes(sample = residual)) +
stat_qq(alpha = 0.4, color = "#e6550d") +
stat_qq_line() +
facet_wrap(~modelo) +
labs(title = "Residuales cuantílicos: Gamma vs. Gaussiana inversa",
x = "Cuantiles teóricos N(0,1)", y = "Residual cuantílico")data.frame(Modelo = c("Gamma", "Gaussiana inversa"),
`V(μ)` = c("μ²", "μ³"),
AIC = c(AIC(sev_gamma), AIC(sev_ig)), check.names = FALSE) |>
tabla(digits = 0, caption = "Comparación de modelos de severidad")| Modelo | V(μ) | AIC |
|---|---|---|
| Gamma | μ² | 67118 |
| Gaussiana inversa | μ³ | 65224 |
Decisión: la Gaussiana inversa tiene un AIC mucho menor y sus puntos siguen la recta en el centro y a la derecha. Para la tarifa usaremos la Gaussiana inversa.
El “piso” en 200 sigue ahí: ninguna distribución lo resuelve, porque es una característica de cómo se registran los datos. En la práctica se trataría aparte, por ejemplo modelando solo lo que cuesta cada siniestro por encima de 200. Algo parecido se hace con los siniestros gigantes: se analizan por separado y su costo se reparte entre todos como un pequeño recargo.
sev_final <- sev_ig
exp(cbind(Factor = coef(sev_final), confint.default(sev_final))) |>
tabla(digits = 3, caption = "Factores multiplicativos de severidad (Gaussiana inversa)")| Factor | 2.5 % | 97.5 % | |
|---|---|---|---|
| (Intercept) | 2041.347 | 1638.301 | 2543.547 |
| agecat2 | 0.848 | 0.673 | 1.068 |
| agecat3 | 0.783 | 0.626 | 0.978 |
| agecat4 | 0.772 | 0.618 | 0.964 |
| agecat5 | 0.704 | 0.553 | 0.896 |
| agecat6 | 0.772 | 0.585 | 1.018 |
| areaB | 0.966 | 0.822 | 1.135 |
| areaC | 1.105 | 0.950 | 1.286 |
| areaD | 1.033 | 0.844 | 1.265 |
| areaE | 1.140 | 0.910 | 1.429 |
| areaF | 1.450 | 1.083 | 1.941 |
| genderM | 1.179 | 1.051 | 1.322 |
Una pregunta que no es estadística: la variable
gender resulta significativa en la severidad. ¿Es aceptable
cobrar distinto por sexo? En la Unión Europea, por ejemplo, la
tarificación por sexo en seguros está prohibida desde 2012. Que una
variable sea significativa no la vuelve legítima: la fase de
entendimiento del negocio también incluye el marco regulatorio y
ético.
Ya tenemos las dos piezas. Para cada cliente calculamos:
\[ \text{Prima pura} = \text{Siniestros esperados en un año} \times \text{Costo esperado de cada siniestro} \]
Para obtener los siniestros en un año completo, le pedimos al modelo de frecuencia la predicción con exposición igual a 1.
prima_pura <- function(datos) {
anual <- mutate(datos, exposure = 1)
frecuencia <- predict(frec_nb, newdata = anual, type = "response")
severidad <- predict(sev_final, newdata = anual, type = "response")
data.frame(frecuencia, severidad, prima_pura = frecuencia * severidad)
}
train <- bind_cols(train, prima_pura(train))
test <- bind_cols(test, prima_pura(test))
summary(test$prima_pura)## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 121 234 281 296 337 843
ggplot(test, aes(prima_pura)) +
geom_histogram(bins = 50, fill = "#31a354", color = "white") +
labs(x = "Prima pura anual estimada", y = "Pólizas",
title = "Distribución de la prima pura en la base de prueba")¿Y si en lugar de dos modelos usamos uno solo? La distribución Tweedie está diseñada para variables que tienen muchos ceros y, cuando no son cero, montos positivos: exactamente como el costo anual de una póliza. Con ella se modela la prima pura directamente.
statmod::tweedie(), var.power es \(p\) y link.power = 0 indica
enlace logarítmico.
tweedie_pp <- glm(
I(claimcst0 / exposure) ~ agecat + veh_body + valor_cat + area + gender,
family = tweedie(var.power = 1.6, link.power = 0), # link.power = 0 ⇒ enlace log
weights = exposure, data = train
)
test$prima_tweedie <- predict(tweedie_pp, newdata = test, type = "response")
cor(test$prima_pura, test$prima_tweedie)## [1] 0.9174
Ambos enfoques producen primas similares: la correlación entre ellas es alta (1 sería idénticas). La ventaja del enfoque frecuencia × severidad es que explica de dónde viene el riesgo; la del Tweedie, que es un solo modelo.
Llegó el momento del “examen”: usamos la base de prueba, que el modelo nunca vio.
Sumamos lo que el modelo esperaba pagar en siniestros y lo comparamos con lo que realmente se pagó. El indicador real / esperado se lee así: 1 significa que acertó; 1,10 que los siniestros fueron un 10 % más de lo previsto (la aseguradora cobró de menos); 0,90 que fueron un 10 % menos (cobró de más).
test <- test |> mutate(esperado = prima_pura * exposure) # costo esperado según exposición real
eval_total <- test |>
summarise(`Siniestros reales` = sum(claimcst0),
`Siniestros esperados` = sum(esperado)) |>
mutate(`Real / esperado` = `Siniestros reales` / `Siniestros esperados`)
eval_total |> tabla(digits = 3, format.args = list(big.mark = ","))| Siniestros reales | Siniestros esperados | Real / esperado |
|---|---|---|
| 1,859,359 | 1,851,337 | 1.004 |
test |>
group_by(agecat) |>
summarise(polizas = n(), siniestros = sum(numclaims),
real = sum(claimcst0), esperado = sum(esperado)) |>
mutate(`Real / esperado` = real / esperado) |>
tabla(digits = 3, format.args = list(big.mark = ","),
caption = "Cociente real/esperado por categoría de edad")| agecat | polizas | siniestros | real | esperado | Real / esperado |
|---|---|---|---|---|---|
| 1 | 1,194 | 112 | 336,168 | 259,648 | 1.295 |
| 2 | 2,602 | 200 | 393,350 | 401,065 | 0.981 |
| 3 | 3,154 | 223 | 387,067 | 437,934 | 0.884 |
| 4 | 3,257 | 234 | 425,449 | 425,966 | 0.999 |
| 5 | 2,060 | 133 | 196,946 | 192,332 | 1.024 |
| 6 | 1,294 | 89 | 120,380 | 134,392 | 0.896 |
Un cociente cercano a 1 en cada segmento indica que el modelo no está subsidiando a unos grupos con otros. Ojo con la lectura: cada segmento tiene solo unas decenas o pocos cientos de siniestros y la severidad es muy variable, así que desviaciones de 10 % a 30 % pueden ser ruido. Antes de cambiar la tarifa de un segmento, se confirma con más periodos o con intervalos de confianza.
Que el total cuadre no basta: un modelo que le cobre lo mismo a todos también puede cuadrar en total. Lo valioso es que distinga a los clientes baratos de los caros. Para comprobarlo, ordenamos las pólizas de prueba de menor a mayor prima estimada, las partimos en 10 grupos del mismo tamaño (deciles) y comparamos en cada grupo el costo que el modelo predijo con el que realmente ocurrió. A este gráfico se le llama lift.
lift <- test |>
mutate(decil = ntile(prima_pura, 10)) |>
group_by(decil) |>
summarise(Predicho = sum(esperado) / sum(exposure),
Observado = sum(claimcst0) / sum(exposure), .groups = "drop")
lift |>
pivot_longer(-decil, names_to = "tipo", values_to = "costo") |>
ggplot(aes(factor(decil), costo, color = tipo, group = tipo)) +
geom_line(linewidth = 1) +
geom_point(size = 2.5) +
scale_color_manual(values = c(Observado = "#e6550d", Predicho = "#2c7fb8")) +
labs(x = "Decil de prima pura estimada (1 = menor riesgo)",
y = "Costo por año de exposición", color = NULL,
title = "Lift: ¿el modelo separa a los asegurados baratos de los caros?") +
theme(legend.position = "top")## [1] 2.77
Si el modelo no sirviera para distinguir clientes, la línea naranja (lo observado) sería plana. En cambio, sube: los clientes que el modelo considera más riesgosos de verdad cuestan más. El número de abajo indica cuántas veces más cuesta el grupo más riesgoso frente al menos riesgoso.
Nota honesta: con variables solo del vehículo y de la edad, el poder de discriminación es limitado. En la industria se agregan historial de siniestros, kilometraje, uso del vehículo o datos telemáticos. El modelo es tan bueno como los datos que lo alimentan.
La prima pura alcanza solo para pagar los siniestros esperados. Pero la aseguradora también tiene gastos (salarios, comisiones a los asesores, tecnología) y necesita una ganancia. La prima comercial, que es el precio que paga el cliente, los incluye:
\[ \text{Prima comercial} = \frac{\text{Prima pura}}{1 - g - u} \]
donde \(g\) es la proporción de gastos (administración, comisiones) y \(u\) el margen de utilidad sobre la prima. Por ejemplo, con gastos del 25 % y utilidad del 5 %, una prima pura de 300 se convierte en una prima comercial de \(300 / 0{,}70 \approx 429\).
cotizar <- function(agecat, veh_body, valor, area, gender,
gastos = 0.25, utilidad = 0.05) {
perfil <- data.frame(
agecat = factor(agecat, levels = levels(autos$agecat)),
veh_body = factor(veh_body, levels = levels(autos$veh_body)),
valor_cat = cut(valor, breaks = c(0, 1, 1.5, 2, 2.5, Inf), right = FALSE,
labels = levels(autos$valor_cat)),
area = factor(area, levels = levels(autos$area)),
gender = factor(gender, levels = levels(autos$gender)),
exposure = 1
)
pp <- prima_pura(perfil)
data.frame(pp, prima_comercial = pp$prima_pura / (1 - gastos - utilidad))
}
# Dos perfiles para comparar
bind_rows(
`Joven, hatchback, zona C` = cotizar(1, "HBACK", 1.2, "C", "M"),
`Mayor, sedán, zona A` = cotizar(6, "SEDAN", 1.2, "A", "F"),
.id = "Perfil"
) |> tabla(digits = 3, row.names = FALSE, caption = "Cotización para dos perfiles")| Perfil | frecuencia | severidad | prima_pura | prima_comercial |
|---|---|---|---|---|
| Joven, hatchback, zona C | 0.201 | 2659 | 535.4 | 764.9 |
| Mayor, sedán, zona A | 0.123 | 1576 | 193.4 | 276.3 |
En producción, esta función se convertiría en un servicio (una API, un aplicativo Shiny o una tabla de factores cargada en el sistema de emisión de pólizas).
base <- exp(coef(frec_nb)["(Intercept)"]) * exp(coef(sev_final)["(Intercept)"])
cat("Prima pura base (perfil de referencia):", round(base, 1), "\n")## Prima pura base (perfil de referencia): 367.7
rel_frec <- exp(coef(frec_nb)[-1])
rel_sev <- exp(coef(sev_final)[-1])
data.frame(Término = c(names(rel_frec), names(rel_sev)),
Componente = c(rep("Frecuencia", length(rel_frec)), rep("Severidad", length(rel_sev))),
Factor = c(rel_frec, rel_sev)) |>
tabla(digits = 3, row.names = FALSE, caption = "Tarifa expresada como factores multiplicativos")| Término | Componente | Factor |
|---|---|---|
| agecat2 | Frecuencia | 0.844 |
| agecat3 | Frecuencia | 0.804 |
| agecat4 | Frecuencia | 0.775 |
| agecat5 | Frecuencia | 0.606 |
| agecat6 | Frecuencia | 0.607 |
| veh_bodyHBACK | Frecuencia | 0.996 |
| veh_bodyHDTOP | Frecuencia | 0.901 |
| veh_bodySTNWG | Frecuencia | 0.923 |
| veh_bodyTRUCK | Frecuencia | 0.879 |
| veh_bodyUTE | Frecuencia | 0.798 |
| veh_bodyOTRO | Frecuencia | 1.180 |
| valor_cat1-1.5 | Frecuencia | 1.122 |
| valor_cat1.5-2 | Frecuencia | 1.151 |
| valor_cat2-2.5 | Frecuencia | 1.295 |
| valor_cat2.5+ | Frecuencia | 1.342 |
| agecat2 | Severidad | 0.848 |
| agecat3 | Severidad | 0.783 |
| agecat4 | Severidad | 0.772 |
| agecat5 | Severidad | 0.704 |
| agecat6 | Severidad | 0.772 |
| areaB | Severidad | 0.966 |
| areaC | Severidad | 1.105 |
| areaD | Severidad | 1.033 |
| areaE | Severidad | 1.140 |
| areaF | Severidad | 1.450 |
| genderM | Severidad | 1.179 |
La prima de cualquier asegurado se obtiene multiplicando la prima base por los factores que le corresponden. Así se comunica una tarifa a un área comercial o a un regulador.
Un modelo implementado no se deja solo. Los costos de reparación suben, cambia la mezcla de clientes y aparecen nuevos riesgos. Simulemos 12 meses del año siguiente en los que la inflación de los costos de reparación es del 1,5 % mensual, mientras la tarifa sigue igual.
Como la severidad es muy volátil, un mes aislado dice poco. Por eso se monitorea el cociente acumulado en lo corrido del año: cada mes suma más información y la banda de variación normal se estrecha.
set.seed(2026)
polizas_mes <- 4000
# Una cartera mensual simulada: pólizas remuestreadas del test, con inflación
simular_mes <- function(inflacion) {
muestra <- test[sample(nrow(test), polizas_mes, replace = TRUE), ]
c(real = sum(muestra$claimcst0) * inflacion, esperado = sum(muestra$esperado))
}
# Escenario sin cambios: 300 años simulados para construir las bandas de control
sin_cambio <- replicate(300, {
meses <- sapply(1:12, function(m) simular_mes(1))
cumsum(meses["real", ]) / cumsum(meses["esperado", ])
})
bandas <- data.frame(mes = 1:12,
inf = apply(sin_cambio, 1, quantile, 0.05),
sup = apply(sin_cambio, 1, quantile, 0.95))
# Año observado: con inflación de 1,5 % mensual
meses_obs <- sapply(1:12, function(m) simular_mes(1.015^m))
monitoreo <- bandas |>
mutate(acumulado = cumsum(meses_obs["real", ]) / cumsum(meses_obs["esperado", ]),
estado = ifelse(acumulado > sup, "Alerta", "Dentro de la banda"))
ggplot(monitoreo, aes(mes)) +
geom_ribbon(aes(ymin = inf, ymax = sup), fill = "#31a354", alpha = 0.15) +
geom_hline(yintercept = 1, linetype = "dashed") +
geom_line(aes(y = acumulado), color = "grey40") +
geom_point(aes(y = acumulado, color = estado), size = 3) +
scale_color_manual(values = c(`Dentro de la banda` = "#31a354", Alerta = "#e6550d"), name = NULL) +
scale_x_continuous(breaks = 1:12) +
labs(x = "Mes del año siguiente", y = "Real / esperado (acumulado)",
title = "Tablero de monitoreo del modelo",
subtitle = "Banda verde: variación normal sin cambios (percentiles 5–95)") +
theme(legend.position = "top")Al principio del año la banda es ancha y la inflación se confunde con el ruido. A medida que se acumulan meses, la banda se estrecha y la desviación sale a la luz.
¿Qué hacer cuando se dispara la alerta? Primero se diagnostica: ¿es un mes atípico o una tendencia? Si es tendencia, se distingue entre un cambio de nivel (inflación: basta con ajustar la prima base) y un cambio de estructura (cambió el riesgo relativo entre segmentos: hay que reentrenar el modelo). Así el ciclo CRISP-DM vuelve a empezar.
| Fase | Lo que hicimos | Supuesto o concepto clave |
|---|---|---|
| Negocio | Definir la prima pura como objetivo y el cociente real/esperado como criterio de éxito | Independencia frecuencia–severidad; marco ético |
| Entender los datos | Identificar ceros, exposición variable y asimetría: eso decidió las familias | Familia exponencial (supuesto 1) |
| Preparación | Agrupar categorías, tramos de valor, separar frecuencia y severidad, train/test | Linealidad en la escala del enlace (supuesto 3) |
| Modelamiento | Poisson → sobredispersión → Binomial negativa; Gamma → falla el diagnóstico → Gaussiana inversa para severidad; Tweedie como alternativa | Relación media–varianza (supuesto 2), offset (supuesto 5) |
| Evaluación | Real/esperado total y por segmento; lift por deciles | Validación fuera de muestra |
| Implementación | Función cotizar() y tarifa como tabla de factores | Prima comercial = PP / (1 − g − u) |
| Monitoreo | Tablero real/esperado con bandas de control | Deriva de datos: nivel vs. estructura |
| Término | Qué significa |
|---|---|
| AIC | Medida para comparar modelos: premia el buen ajuste y castiga la complejidad. Más pequeño, mejor. |
| Binomial negativa | Distribución para conteos que permite más variabilidad que la Poisson. |
| Coeficiente de variación | Desviación estándar dividida por la media. Mide qué tan variables son los datos en relación con su promedio. |
| Devianza | Qué tan lejos está el modelo de un ajuste perfecto. Más pequeña, mejor. |
| Deciles | División de los datos ordenados en 10 grupos del mismo tamaño. |
| Diagnóstico | Revisión de si el modelo cumple sus supuestos. |
| Error estándar (EE) | Qué tan seguro está el modelo de un coeficiente. Pequeño = mucha seguridad. |
| Exposición | Tiempo que una póliza estuvo vigente, en años. |
| Factor | El exponencial de un coeficiente, e^β. Multiplica la predicción: 1,2 = +20 %; 0,8 = −20 %. |
| Frecuencia | Número de siniestros por año de exposición. |
| Función de enlace | El “traductor” entre la suma de variables y la escala de Y. En seguros suele ser el logaritmo. |
| Gaussiana inversa | Distribución para montos positivos con una cola derecha aún más larga que la Gamma. |
| GLM | Modelo Lineal Generalizado: regresión que permite elegir la distribución y la escala. |
| Intervalo de confianza | Rango donde razonablemente está el verdadero valor de un parámetro. |
| Lift | Gráfico que muestra si el modelo distingue a los clientes de bajo y alto costo. |
| Máxima verosimilitud | Método para estimar coeficientes: elegir los valores que hacen más creíbles los datos observados. |
| Monitoreo | Seguimiento del modelo después de implementado, para detectar si deja de funcionar. |
| Offset | Variable que entra al modelo sin coeficiente, para ajustar por el tiempo de exposición. |
| p-valor | Probabilidad de ver un resultado así si la variable no tuviera efecto. Menor a 0,05 suele indicar que sí aporta. |
| Prima comercial | Precio que paga el cliente: prima pura más gastos y utilidad. |
| Prima pura | Costo esperado de los siniestros de un cliente en un año: frecuencia × severidad. |
| Residual | Diferencia entre lo observado y lo que predice el modelo. |
| Residual cuantílico | Residual transformado para que, si el modelo es correcto, se comporte como una normal. |
| Severidad | Costo promedio de cada siniestro. |
| Sobredispersión | Cuando los datos varían más de lo que supone la distribución elegida. |
| Train / test | Datos para entrenar el modelo y datos guardados para evaluarlo. |
| Tweedie | Distribución para variables con muchos ceros y montos positivos, como el costo anual de una póliza. |
| VIF | Indicador de si dos o más variables explicativas repiten la misma información. |
dataCar.)