Un modelo estadístico para redes es una familia de distribuciones de probabilidad sobre un conjunto de redes posibles:
\[ \mathcal P=\{p(\mathbf Y\mid\boldsymbol\theta):\boldsymbol\theta\in\Theta\}, \]
donde \(\mathbf Y\) es la matriz de adyacencia, \(\boldsymbol\theta\) representa los parámetros del modelo y \(\Theta\) es su espacio de valores posibles.
La utilidad de los modelos de redes consiste en:
Consideramos grafos simples, binarios y no dirigidos, con \(n\) vértices fijos y distinguibles, donde \(y_{ij}=y_{ji}\in\{0,1\}\) y \(y_{ii}=0\).
Existen distintas familias de modelos de grafos aleatorios, entre las que se destacan:
Estudiaremos el modelo Bernoulli homogéneo, también conocido como modelo de Erdős–Rényi, ampliamente utilizado como modelo de referencia en el análisis de redes.
Este modelo supone que cada díada presenta una arista de manera independiente, con una probabilidad común \(\theta\):
\[ y_{i,j}\mid\theta\overset{\mathrm{iid}}{\sim} \operatorname{Bernoulli}(\theta),\qquad 1\leq i<j\leq n, \qquad 0<\theta<1. \]
Escribamos \(q=\binom n2\) para el número de díadas posibles y \(s=\sum_{i<j}y_{ij}\) para el número de aristas observadas. La distribución conjunta es
\[ p(\mathbf Y\mid\theta) =\prod_{i<j}\theta^{y_{i,j}}(1-\theta)^{1-y_{i,j}} =\theta^s(1-\theta)^{q-s}. \]
El modelo es homogéneo porque no considera diferencias entre vértices o díadas, y es independiente porque la presencia de una arista no modifica la probabilidad de las demás.
Número de aristas: \(S\sim\textsf{Bin}(q,\theta)\), con \(\textsf{E}(S)=q\theta\) y \(\textsf{Var}(S)=q\theta(1-\theta)\), de donde la densidad esperada es \(\theta\).
Distribución de grados: \(D_i\sim\textsf{Bin}(n-1,\theta)\), con \(\textsf{E}(D_i)=(n-1)\theta\). Los grados no son independientes, pues \(\textsf{Cov}(D_i,D_j)=\theta(1-\theta)\) para \(i\neq j\).
Régimen disperso: si \(\theta=k/(n-1)\), con \(k\) fijo, el grado esperado permanece constante, la densidad tiende a cero y \(D_i\) converge en distribución a una \(\textsf{Poisson}(k)\) cuando \(n\to\infty\).
Transitividad: debido a la independencia de las aristas, la probabilidad de cerrar una tripla conectada es \(\theta\). Por tanto, la transitividad global converge en probabilidad a \(\theta\) cuando \(n\to\infty\) y \(\theta\) permanece fijo. En el régimen disperso con \(\theta=k/(n-1)\), tiende a cero.
Conectividad y distancias: la red tiende a estar conectada cuando \(\theta\) supera suficientemente el umbral \(\log(n)/n\), y desconectada cuando está suficientemente por debajo, para \(n\) grande. En redes desconectadas, la distancia promedio entre vértices conectados por algún camino puede ser pequeña, aunque la red esté fragmentada.
Uniformidad condicional: dado \(S=s\), todos los grafos con \(n\) vértices y \(s\) aristas son equiprobables:
\[ \textsf{Pr}(\mathbf{Y}=\mathbf{y}\mid S=s)=\binom{q}{s}^{-1}. \]
Simulamos una red dispersa con grado esperado aproximadamente igual a \(k=2\).
La probabilidad de enlace es \(\theta=k/(n-1)\).
# Paquetes necesarios: install.packages(c("igraph", "igraphdata"))
suppressMessages(suppressWarnings(library(igraph)))
# Parámetros del modelo
n_sim <- 120
k <- 2
theta_sim <- k / (n_sim - 1)
# Simular un grafo simple no dirigido
set.seed(42)
g_sim <- sample_gnp(
n = n_sim,
p = theta_sim,
directed = FALSE,
loops = FALSE
)
# Visualizar la red
set.seed(42)
plot(
g_sim,
layout = layout_with_fr(g_sim),
vertex.size = 5,
vertex.label = NA,
vertex.color = "steelblue",
vertex.frame.color = NA,
edge.color = "gray65",
main = "Modelo Bernoulli homogéneo"
)Las componentes y la proporción de pares alcanzables ayudan a interpretar las distancias en una red posiblemente desconectada.
# Identificar componentes conexas
comp_sim <- components(g_sim)
tamanos <- comp_sim$csize
# Proporción de pares que pueden comunicarse
pares_alcanzables <- sum(choose(tamanos, 2))
prop_alcanzables <- pares_alcanzables / choose(n_sim, 2)
# Resumen de propiedades observadas y teóricas
resumen_sim <- data.frame(
Estadistico = c(
"Vértices", "Aristas", "Densidad", "Grado medio",
"Grado medio esperado", "Componentes", "Componente mayor (%)",
"Pares alcanzables (%)", "Distancia entre alcanzables", "Transitividad"
),
Valor = c(
vcount(g_sim), ecount(g_sim), edge_density(g_sim),
mean(degree(g_sim)), (n_sim - 1) * theta_sim,
comp_sim$no, 100 * max(tamanos) / n_sim,
100 * prop_alcanzables,
mean_distance(g_sim, directed = FALSE, unconnected = TRUE),
transitivity(g_sim, type = "global", isolates = "zero")
)
)
# Mostrar resultados
knitr::kable(resumen_sim, digits = 3)| Estadistico | Valor |
|---|---|
| Vértices | 120.000 |
| Aristas | 113.000 |
| Densidad | 0.016 |
| Grado medio | 1.883 |
| Grado medio esperado | 2.000 |
| Componentes | 25.000 |
| Componente mayor (%) | 75.000 |
| Pares alcanzables (%) | 56.190 |
| Distancia entre alcanzables | 5.551 |
| Transitividad | 0.014 |
# Comparar distribución de grados y aproximación Poisson
soporte_grados <- 0:max(degree(g_sim))
comparacion_grados <- data.frame(
Grado = soporte_grados,
Observado = tabulate(degree(g_sim) + 1, nbins = n_sim)[soporte_grados + 1] / n_sim,
Binomial = dbinom(soporte_grados, size = n_sim - 1, prob = theta_sim),
Poisson = dpois(soporte_grados, lambda = k)
)
# Mostrar las primeras categorías
knitr::kable(head(comparacion_grados, 12), digits = 5)| Grado | Observado | Binomial | Poisson |
|---|---|---|---|
| 0 | 0.15833 | 0.13305 | 0.13534 |
| 1 | 0.30833 | 0.27066 | 0.27067 |
| 2 | 0.19167 | 0.27297 | 0.27067 |
| 3 | 0.20000 | 0.18198 | 0.18045 |
| 4 | 0.11667 | 0.09021 | 0.09022 |
| 5 | 0.02500 | 0.03547 | 0.03609 |
Repetimos el experimento \(B\) veces para construir distribuciones de referencia de los estadísticos.
Los cuantiles 2.5 % y 97.5 % forman un intervalo de simulación, no un intervalo de confianza para \(\theta\).
# Número de redes simuladas
B_sim <- 1000
# Reservar memoria para las estadísticas
frecuencias_grado <- matrix(0, nrow = B_sim, ncol = n_sim)
distancias_sim <- numeric(B_sim)
transitividad_sim <- numeric(B_sim)
alcanzables_sim <- numeric(B_sim)
# Repetir la simulación
set.seed(123)
for (b in seq_len(B_sim)) {
# Generar una nueva red
g_b <- sample_gnp(
n = n_sim,
p = theta_sim,
directed = FALSE,
loops = FALSE
)
# Registrar frecuencias de todos los grados posibles
grados_b <- degree(g_b)
frecuencias_grado[b, ] <- tabulate(grados_b + 1, nbins = n_sim) / n_sim
# Registrar distancia entre pares alcanzables
distancias_sim[b] <- mean_distance(
g_b, directed = FALSE, unconnected = TRUE
)
# Registrar transitividad global
transitividad_sim[b] <- transitivity(
g_b, type = "global", isolates = "zero"
)
# Registrar la fracción de pares alcanzables
tamanos_b <- components(g_b)$csize
alcanzables_sim[b] <- sum(choose(tamanos_b, 2)) / choose(n_sim, 2)
}# Calcular cuantiles de las frecuencias por grado
cuantiles_grado <- apply(
frecuencias_grado, 2, quantile, probs = c(0.025, 0.5, 0.975)
)
# Mostrar todos los grados observados en las simulaciones
ultimo_grado <- max(which(colSums(frecuencias_grado) > 0)) - 1
x_grados <- 0:ultimo_grado
# Preparar gráfico
plot(
x_grados, cuantiles_grado[2, x_grados + 1],
type = "n", xlab = "Grado", ylab = "Proporción de vértices",
ylim = c(0, max(cuantiles_grado[3, x_grados + 1])),
main = "Distribución de grado"
)
# Intervalo central de simulación del 95 %
segments(
x_grados, cuantiles_grado[1, x_grados + 1],
x_grados, cuantiles_grado[3, x_grados + 1],
col = "gray55"
)
# Mediana simulada
points(x_grados, cuantiles_grado[2, x_grados + 1], pch = 16)
# Distribución binomial exacta
points(
x_grados,
dbinom(x_grados, size = n_sim - 1, prob = theta_sim),
col = "blue", pch = 17
)
# Aproximación Poisson
points(x_grados, dpois(x_grados, lambda = k), col = "red3", pch = 1)
# Identificar curvas
legend(
"topright",
legend = c("Mediana simulada", "Binomial", "Poisson", "Intervalo 95 %"),
col = c("black", "blue", "red3", "gray55"),
pch = c(16, 17, 1, NA),
lty = c(NA, NA, NA, 1), bty = "n"
)# Organizar los tres histogramas
par(mfrow = c(1, 3), mar = c(4, 4, 2, 1))
# Distribución de la distancia media entre alcanzables
hist(
distancias_sim, col = "gray85", border = "white",
main = "Distancia media", xlab = "Solo pares alcanzables"
)
abline(v = median(distancias_sim), col = "blue", lwd = 2)
# Distribución de la transitividad
hist(
transitividad_sim, col = "gray85", border = "white",
main = "Transitividad", xlab = "Transitividad global"
)
abline(v = median(transitividad_sim), col = "blue", lwd = 2)
# Distribución de pares alcanzables
hist(
alcanzables_sim, col = "gray85", border = "white",
main = "Alcanzabilidad", xlab = "Fracción de pares alcanzables"
)
abline(v = median(alcanzables_sim), col = "blue", lwd = 2)El estimador de máxima verosimilitud (MLE) es el valor de \(\theta\) que maximiza la probabilidad de la red observada.
La log-verosimilitud es
\[ \ell(\theta)=s\log\theta+(q-s)\log(1-\theta). \]
Para \(0<s<q\), al resolver \(\partial\ell(\theta)/\partial\theta=0\) se obtiene
\[ \widehat\theta=\frac{s}{q}=\frac{2s}{n(n-1)} =\text{densidad observada}. \]
Cuando \(s=0\) o \(s=q\), el máximo está en 0 o 1, respectivamente; por tanto, si \(\Theta=(0,1)\), el MLE no se alcanza dentro del espacio abierto.
Utilizaremos la red de aeropuertos de Estados Unidos correspondiente a diciembre de 2010.
Dos aeropuertos estarán conectados si existe al menos una ruta aérea entre ellos, independientemente de su dirección.
# Cargar la red
data("USairports", package = "igraphdata")
# Actualizar el formato del grafo
USairports <- igraph::upgrade_graph(USairports)
# Convertir a una red no dirigida
g <- as_undirected(
USairports,
mode = "collapse",
edge.attr.comb = "ignore"
)
# Eliminar bucles y aristas múltiples
g <- simplify(g, remove.multiple = TRUE, remove.loops = TRUE)
# Tamaño de la red
(n <- vcount(g))## [1] 755
## [1] 4623
## [1] 284635
Bajo el modelo Bernoulli homogéneo, el estimador de máxima verosimilitud de \(\theta\) corresponde a la densidad observada.
## [1] 0.01624185
# Definir la log-verosimilitud
log_verosimilitud <- function(theta) {
s * log(theta) + (q - s) * log1p(-theta)
}
# Construir valores cercanos al estimador
theta_min <- max(1e-6, 0.5 * theta_hat)
theta_max <- min(1 - 1e-6, 1.5 * theta_hat)
valores_theta <- seq(theta_min, theta_max, length.out = 300)
# Evaluar la log-verosimilitud
valores_loglik <- log_verosimilitud(valores_theta)
# Graficar la función
plot(
valores_theta, valores_loglik,
type = "l",
lwd = 2,
xlab = expression(theta),
ylab = "Log-verosimilitud",
main = "Estimación por máxima verosimilitud"
)
# Identificar el estimador
abline(v = theta_hat, col = "red3", lty = 2, lwd = 2)La bondad de ajuste evalúa si un modelo reproduce adecuadamente las propiedades estructurales de una red observada, comparando estadísticos \(t(\mathbf y)\) con sus distribuciones bajo el modelo estimado.
Una coincidencia en la densidad no garantiza un buen ajuste en otras propiedades.
Para evaluar el modelo Bernoulli homogéneo, simulamos \(B\) redes independientes utilizando el estimador de máxima verosimilitud \(\widehat{\theta}\):
\[ \mathbf Y^{(b)}\sim G(n,\widehat{\theta}), \qquad b=1,\ldots,B. \]
El procedimiento consiste en:
Consideraremos tres estadísticos de bondad de ajuste:
La densidad esperada coincide con la observada, pues \(\widehat{\theta}\) se estima a partir de ella. Por tanto, no permite evaluar independientemente la bondad de ajuste.
La transitividad y la distancia geodésica promedio permiten evaluar si el modelo reproduce adecuadamente la formación de triángulos y las distancias entre vértices, respectivamente.
En redes desconectadas, la distancia promedio se calcula únicamente entre pares alcanzables, por lo que no refleja completamente la fragmentación.
## [1] 0.01624185
## [1] 0.3384609
## [1] 3.447169
# Número de simulaciones
B <- 1000
# Almacenar resultados
densidad_rep <- numeric(B)
trans_rep <- numeric(B)
distancia_rep <- numeric(B)
# Simular redes bajo el modelo estimado
set.seed(42)
for (b in seq_len(B)) {
# Generar una red Bernoulli homogénea
g_b <- sample_gnp(
n = n,
p = theta_hat,
directed = FALSE,
loops = FALSE
)
# Calcular la densidad
densidad_rep[b] <- edge_density(g_b)
# Calcular la transitividad
trans_rep[b] <- transitivity(g_b, type = "global")
# Calcular la distancia geodésica promedio
distancia_rep[b] <- mean_distance(
g_b,
directed = FALSE,
unconnected = TRUE
)
}
# Resumir los estadísticos
comparacion <- data.frame(
Estadistico = c(
"Densidad",
"Transitividad",
"Distancia geodésica promedio"
),
Observado = c(
densidad_obs,
trans_obs,
distancia_obs
),
Media_simulada = c(
mean(densidad_rep),
mean(trans_rep),
mean(distancia_rep)
),
Q025 = c(
quantile(densidad_rep, 0.025),
quantile(trans_rep, 0.025),
quantile(distancia_rep, 0.025)
),
Q975 = c(
quantile(densidad_rep, 0.975),
quantile(trans_rep, 0.975),
quantile(distancia_rep, 0.975)
)
)
# Mostrar resultados
knitr::kable(comparacion, digits = 5)| Estadistico | Observado | Media_simulada | Q025 | Q975 |
|---|---|---|---|---|
| Densidad | 0.01624 | 0.01624 | 0.01577 | 0.01670 |
| Transitividad | 0.33846 | 0.01624 | 0.01445 | 0.01814 |
| Distancia geodésica promedio | 3.44717 | 2.90019 | 2.87339 | 2.93089 |
Los cuantiles 2.5 % y 97.5 % delimitan un intervalo de simulación del 95 % para cada estadístico.
# Organizar los gráficos
par(mfrow = c(1, 3), mar = c(4, 4, 2, 1))
# Distribución simulada de la densidad
hist(
densidad_rep,
breaks = 25,
col = "gray85",
border = "gray85",
xlim = range(c(densidad_rep, densidad_obs)),
main = "Densidad",
xlab = "Densidad",
ylab = "Frecuencia"
)
# Identificar la densidad observada
abline(v = densidad_obs, col = "red3", lwd = 2, lty = 2)
# Distribución simulada de la transitividad
hist(
trans_rep,
breaks = 25,
col = "gray85",
border = "gray85",
xlim = range(c(trans_rep, trans_obs)),
main = "Transitividad global",
xlab = "Transitividad",
ylab = "Frecuencia"
)
# Identificar la transitividad observada
abline(v = trans_obs, col = "red3", lwd = 2, lty = 2)
# Distribución simulada de la distancia geodésica promedio
hist(
distancia_rep,
breaks = 25,
col = "gray85",
border = "gray85",
xlim = range(c(distancia_rep, distancia_obs)),
main = "Distancia geodésica promedio",
xlab = "Distancia geodésica",
ylab = "Frecuencia"
)
# Identificar la distancia observada
abline(v = distancia_obs, col = "red3", lwd = 2, lty = 2)Las líneas rojas representan los valores observados.
Una separación importante respecto de las distribuciones simuladas indica que el modelo no reproduce adecuadamente la propiedad evaluada.
Estimamos las probabilidades de cola a partir de las \(B\) redes simuladas:
\[ \widehat p_{\mathrm{sup}}= \frac{1+\sum_{b=1}^{B} I(t_b\geq t_{\mathrm{obs}})}{B+1}, \qquad \widehat p_{\mathrm{inf}}= \frac{1+\sum_{b=1}^{B} I(t_b\leq t_{\mathrm{obs}})}{B+1}. \]
Una medida bilateral aproximada es
\[ \widehat p_{\mathrm{bil}}= \min\left\{ 1,\, 2\min(\widehat p_{\mathrm{sup}},\widehat p_{\mathrm{inf}}) \right\}. \]
# Transitividad: probabilidades de cola
p_sup_trans <- (1 + sum(trans_rep >= trans_obs)) / (B + 1)
p_inf_trans <- (1 + sum(trans_rep <= trans_obs)) / (B + 1)
# Transitividad: valor bilateral
p_bil_trans <- min(1, 2 * min(p_sup_trans, p_inf_trans))
# Distancia geodésica: probabilidades de cola
p_sup_dist <- (1 + sum(distancia_rep >= distancia_obs)) / (B + 1)
p_inf_dist <- (1 + sum(distancia_rep <= distancia_obs)) / (B + 1)
# Distancia geodésica: valor bilateral
p_bil_dist <- min(1, 2 * min(p_sup_dist, p_inf_dist))
# Resumir resultados
resultados <- data.frame(
Estadistico = c("Transitividad", "Distancia geodésica promedio"),
p_superior = c(p_sup_trans, p_sup_dist),
p_inferior = c(p_inf_trans, p_inf_dist),
p_bilateral = c(p_bil_trans, p_bil_dist)
)
# Mostrar resultados
knitr::kable(resultados, digits = 5)| Estadistico | p_superior | p_inferior | p_bilateral |
|---|---|---|---|
| Transitividad | 0.001 | 1 | 0.002 |
| Distancia geodésica promedio | 0.001 | 1 | 0.002 |
Estos valores son medidas aproximadas de discrepancia, obtenidas mediante simulaciones del modelo con \(\widehat{\theta}\).
Un valor \(p\) pequeño sugiere que el modelo no reproduce adecuadamente el estadístico evaluado, mientras que un valor grande no demuestra que el modelo sea correcto.
La validación cruzada por díadas evalúa la capacidad predictiva del modelo sobre díadas reservadas para prueba, cuyos estados de conexión no se utilizan durante la estimación.
El procedimiento consiste en:
Consideramos dos dimensiones:
En el modelo Bernoulli homogéneo, todas las díadas de un mismo fold reciben idéntica probabilidad. Por tanto, el modelo no tiene capacidad discriminativa y su AUC es 0.5.
# Extraer las díadas no ordenadas
Y <- as.matrix(as_adjacency_matrix(g, sparse = FALSE))
observado <- as.integer(Y[lower.tri(Y)])
q <- length(observado)
# Construir diez folds de tamaños similares
L <- 5
set.seed(789)
fold <- sample(rep(seq_len(L), length.out = q))
# Resumir las díadas de cada fold
resumen_folds <- data.frame(
Fold = seq_len(L),
Diadas = as.integer(tabulate(fold, nbins = L)),
Enlaces = as.integer(tabulate(fold[observado == 1], nbins = L))
)
# Calcular proporciones de enlaces
resumen_folds$Proporcion <- resumen_folds$Enlaces / resumen_folds$Diadas
# Mostrar resultados
knitr::kable(resumen_folds, digits = 5)| Fold | Diadas | Enlaces | Proporcion |
|---|---|---|---|
| 1 | 56927 | 859 | 0.01509 |
| 2 | 56927 | 978 | 0.01718 |
| 3 | 56927 | 924 | 0.01623 |
| 4 | 56927 | 933 | 0.01639 |
| 5 | 56927 | 929 | 0.01632 |
El Brier score y la pérdida logarítmica (log-loss) evalúan la calidad de las probabilidades predichas:
\[ \text{Brier}=\frac{1}{m}\sum_{k=1}^{m}(y_k-\widehat p_k)^2, \]
\[ \text{Log-loss}=-\frac{1}{m}\sum_{k=1}^{m} \left[y_k\log(\widehat p_k)+(1-y_k)\log(1-\widehat p_k)\right], \]
donde \(m\) es el número de díadas evaluadas, \(y_k\in\{0,1\}\) indica la existencia de un enlace y \(\widehat p_k\) su probabilidad estimada.
En ambos casos, valores menores indican mejor desempeño predictivo.
# Almacenar las probabilidades predichas
prob_cv <- numeric(q)
theta_fold <- numeric(L)
# Validación cruzada
for (l in seq_len(L)) {
# Identificar entrenamiento y prueba
entrenamiento <- fold != l
prueba <- fold == l
# Estimar theta con los datos de entrenamiento
theta_fold[l] <- mean(observado[entrenamiento])
# Predecir las díadas de prueba
prob_cv[prueba] <- theta_fold[l]
}
# Calcular Brier score
brier <- mean((observado - prob_cv)^2)
# Evitar logaritmos de cero
epsilon <- 1e-12
prob_segura <- pmin(pmax(prob_cv, epsilon), 1 - epsilon)
# Calcular pérdida logarítmica
logloss <- -mean(
observado * log(prob_segura) + (1 - observado) * log(1 - prob_segura)
)
# Resumir resultados
metricas <- data.frame(
Metrica = c("Brier score", "Log-loss"),
Valor = c(brier, logloss)
)
knitr::kable(metricas, digits = 5)| Metrica | Valor |
|---|---|
| Brier score | 0.01598 |
| Log-loss | 0.08304 |
Para clasificar las díadas, definimos un umbral \(c\), de forma que se predice un enlace cuando \(\widehat \theta_{i,j}\geq c\).
La matriz de confusión permite calcular:
# Definir el umbral
umbral <- 0.5
# Clasificar las díadas
predicho <- as.integer(prob_cv >= umbral)
# Construir la matriz de confusión
matriz_confusion <- table(
Predicho = factor(predicho, levels = c(0, 1)),
Observado = factor(observado, levels = c(0, 1))
)
matriz_confusion## Observado
## Predicho 0 1
## 0 280012 4623
## 1 0 0
# Identificar las cuatro categorías
VN <- matriz_confusion["0", "0"]
FN <- matriz_confusion["0", "1"]
FP <- matriz_confusion["1", "0"]
VP <- matriz_confusion["1", "1"]
# Calcular métricas de clasificación
sensibilidad <- if (VP + FN > 0) VP / (VP + FN) else NA_real_
especificidad <- if (VN + FP > 0) VN / (VN + FP) else NA_real_
precision <- if (VP + FP > 0) VP / (VP + FP) else NA_real_
exactitud <- (VP + VN) / q
# Resumir resultados
clasificacion <- data.frame(
Metrica = c("Sensibilidad", "Especificidad", "Precisión", "Exactitud"),
Valor = c(sensibilidad, especificidad, precision, exactitud)
)
knitr::kable(clasificacion, digits = 5)| Metrica | Valor |
|---|---|
| Sensibilidad | 0.00000 |
| Especificidad | 1.00000 |
| Precisión | NA |
| Exactitud | 0.98376 |
El modelo presenta sensibilidad nula y especificidad perfecta, pues clasifica todas las díadas como ausencias de enlace.
Aunque alcanza una exactitud del 98.38 %, no identifica ningún enlace existente. Esto evidencia que una alta exactitud no implica buena capacidad predictiva en redes dispersas.
Estos resultados corresponden a la clasificación con umbral \(0.5\).
La curva ROC representa la sensibilidad frente a la tasa de falsos positivos para distintos umbrales.
El AUC resume la capacidad discriminativa: 0.5 indica ausencia de discriminación y 1 discriminación perfecta.
En el Bernoulli homogéneo, las probabilidades son constantes dentro de cada fold. Por tanto, la ROC tiene únicamente los puntos operativos \((0,0)\) y \((1,1)\), cuya interpolación corresponde a la diagonal.
# Cargar la librería
suppressMessages(suppressWarnings(library(pROC)))
# Almacenar AUC por fold
auc_folds <- numeric(L)
# Calcular AUC en cada fold
for (l in seq_len(L)) {
# Seleccionar las díadas de prueba
prueba <- fold == l
y <- observado[prueba]
p <- prob_cv[prueba]
# Verificar que existen ambas clases
if (length(unique(y)) == 2) {
# Calcular la curva ROC
roc_l <- roc(
response = y,
predictor = p,
levels = c(0, 1),
direction = "<",
quiet = TRUE
)
# Extraer el AUC
auc_folds[l] <- as.numeric(auc(roc_l))
} else {
# AUC indefinido si falta alguna clase
auc_folds[l] <- NA_real_
}
}
# Mostrar resultados
resultados_auc <- data.frame(
Fold = seq_len(L),
AUC = auc_folds
)
knitr::kable(resultados_auc, digits = 4)| Fold | AUC |
|---|---|
| 1 | 0.5 |
| 2 | 0.5 |
| 3 | 0.5 |
| 4 | 0.5 |
| 5 | 0.5 |
El AUC es 0.5 en cada fold cuando están presentes ambas clases.
El modelo de vecinos comunes incorpora información sobre la estructura local de la red para estimar probabilidades de enlace heterogéneas, lo que puede mejorar la capacidad predictiva y discriminativa.
Dos vértices \(i\) y \(j\) tienen un vecino común cuando ambos están conectados con un tercer vértice.
Su número se define como
\[ c_{i,j}=\sum_{k\neq i,j}y_{i,k}\,y_{j,k}. \]
Modelamos la probabilidad de enlace mediante una regresión logística:
\[ y_{i,j}\mid c_{i,j}\sim\textsf{Bernoulli}(\theta_{i,j}), \qquad \textsf{logit}(\theta_{i,j})= \beta_0+\beta_1\log(1+c_{i,j}). \]
La transformación \(\log(1+c_{i,j})\) reduce la influencia de valores elevados y permite incluir díadas sin vecinos comunes.
A diferencia del Bernoulli homogéneo, este modelo permite asignar probabilidades diferentes a cada díada.
Evaluamos si el modelo de vecinos comunes reproduce la densidad, la transitividad global y la distancia geodésica promedio de la red observada.
Ajustamos el modelo utilizando todas las díadas y simulamos \(B\) redes con las probabilidades de enlace estimadas.
# Construir la matriz de adyacencia
Y <- as.matrix(as_adjacency_matrix(g, sparse = FALSE))
# Extraer los enlaces observados
y <- as.integer(Y[lower.tri(Y)])
# Calcular los vecinos comunes
matriz_vecinos <- Y %*% Y
vecinos <- log1p(matriz_vecinos[lower.tri(Y)])
# Construir los datos
datos <- data.frame(enlace = y, vecinos = vecinos)
# Ajustar el modelo logístico
modelo_vecinos <- glm(
enlace ~ vecinos,
data = datos,
family = binomial()
)
# Obtener las probabilidades estimadas
p_hat <- predict(modelo_vecinos, type = "response")
# Resumir el modelo
summary(modelo_vecinos)##
## Call:
## glm(formula = enlace ~ vecinos, family = binomial(), data = datos)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -6.20270 0.03610 -171.8 <2e-16 ***
## vecinos 2.06404 0.01673 123.4 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 47266 on 284634 degrees of freedom
## Residual deviance: 24990 on 284633 degrees of freedom
## AIC: 24994
##
## Number of Fisher Scoring iterations: 8
# Calcular los estadísticos observados
estadisticos_obs <- c(
Densidad = edge_density(g),
Transitividad = transitivity(g, type = "global"),
Distancia = mean_distance(g, directed = FALSE, unconnected = TRUE)
)
# Número de simulaciones
B <- 1000
# Almacenar los estadísticos simulados
estadisticos_rep <- matrix(NA_real_, nrow = B, ncol = 3)
colnames(estadisticos_rep) <- names(estadisticos_obs)
# Simular redes bajo el modelo ajustado
set.seed(42)
for (b in seq_len(B)) {
# Simular los enlaces
enlaces_b <- rbinom(q, size = 1, prob = p_hat)
# Construir una matriz de adyacencia vacía
Y_b <- matrix(0, nrow = n, ncol = n)
# Incorporar los enlaces simulados
Y_b[lower.tri(Y_b)] <- enlaces_b
# Garantizar simetría
Y_b <- Y_b + t(Y_b)
# Construir la red simulada
g_b <- graph_from_adjacency_matrix(
Y_b,
mode = "undirected",
diag = FALSE
)
# Calcular los estadísticos
estadisticos_rep[b, 1] <- edge_density(g_b)
estadisticos_rep[b, 2] <- transitivity(g_b, type = "global")
estadisticos_rep[b, 3] <- mean_distance(
g_b,
directed = FALSE,
unconnected = TRUE
)
}
# Resumir los resultados
comparacion_vecinos <- data.frame(
Estadistico = names(estadisticos_obs),
Observado = as.numeric(estadisticos_obs),
Media_simulada = colMeans(estadisticos_rep),
Q025 = apply(estadisticos_rep, 2, quantile, probs = 0.025),
Q975 = apply(estadisticos_rep, 2, quantile, probs = 0.975)
)
# Mostrar resultados
knitr::kable(comparacion_vecinos, digits = 5)| Estadistico | Observado | Media_simulada | Q025 | Q975 | |
|---|---|---|---|---|---|
| Densidad | Densidad | 0.01624 | 0.01624 | 0.01587 | 0.01660 |
| Transitividad | Transitividad | 0.33846 | 0.36955 | 0.35845 | 0.38065 |
| Distancia | Distancia | 3.44717 | 3.62533 | 3.51056 | 3.73707 |
# Organizar los gráficos
par(mfrow = c(1, 3), mar = c(4, 4, 2, 1))
# Graficar las distribuciones simuladas
for (k in seq_len(3)) {
# Seleccionar el estadístico
valores <- estadisticos_rep[, k]
observado_k <- estadisticos_obs[k]
# Graficar la distribución
hist(
valores,
breaks = 25,
col = "gray85",
border = "white",
xlim = range(c(valores, observado_k)),
main = names(estadisticos_obs)[k],
xlab = "Valor del estadístico",
ylab = "Frecuencia"
)
# Identificar el valor observado
abline(v = observado_k, col = "red3", lwd = 2, lty = 2)
}# Almacenar las probabilidades bilaterales aproximadas
p_bilateral <- numeric(3)
# Evaluar cada estadístico
for (k in seq_len(3)) {
# Extraer los valores
valores <- estadisticos_rep[, k]
observado_k <- estadisticos_obs[k]
# Calcular las probabilidades de cola
p_sup <- (1 + sum(valores >= observado_k)) / (B + 1)
p_inf <- (1 + sum(valores <= observado_k)) / (B + 1)
# Calcular la medida bilateral
p_bilateral[k] <- min(1, 2 * min(p_sup, p_inf))
}
# Mostrar resultados descriptivos
resultados_ajuste <- data.frame(
Estadistico = names(estadisticos_obs),
p_bilateral = p_bilateral
)
knitr::kable(resultados_ajuste, digits = 4)| Estadistico | p_bilateral |
|---|---|
| Densidad | 1.000 |
| Transitividad | 0.002 |
| Distancia | 0.002 |
Utilizamos los mismos folds de la validación anterior.
Para evitar filtraciones de información, los vecinos comunes se calculan exclusivamente con las aristas de entrenamiento.
# Identificar las díadas no ordenadas
indices <- which(lower.tri(Y), arr.ind = TRUE)
# Almacenar probabilidades y coeficientes
prob_vecinos <- numeric(q)
coeficientes <- matrix(NA_real_, nrow = L, ncol = 2)
# Validación cruzada
for (l in seq_len(L)) {
# Identificar entrenamiento y prueba
entrenamiento <- fold != l
prueba <- fold == l
# Construir la red de entrenamiento
Y_train <- Y
# Identificar las díadas de prueba
i <- indices[prueba, 1]
j <- indices[prueba, 2]
# Ocultar los enlaces de prueba en ambas direcciones
Y_train[cbind(i, j)] <- 0
Y_train[cbind(j, i)] <- 0
# Calcular el número de vecinos comunes
matriz_vecinos <- Y_train %*% Y_train
# Extraer los vecinos comunes de cada díada
vecinos <- matriz_vecinos[lower.tri(matriz_vecinos)]
# Construir los datos de entrenamiento
datos_train <- data.frame(
enlace = observado[entrenamiento],
vecinos = log1p(vecinos[entrenamiento])
)
# Ajustar la regresión logística
modelo <- glm(
enlace ~ vecinos,
data = datos_train,
family = binomial()
)
# Guardar los coeficientes estimados
coeficientes[l, ] <- coef(modelo)
# Construir los datos de prueba
datos_test <- data.frame(
vecinos = log1p(vecinos[prueba])
)
# Predecir las probabilidades de enlace
prob_vecinos[prueba] <- predict(
modelo,
newdata = datos_test,
type = "response"
)
}
# Resumir los coeficientes estimados
resultados_coef <- data.frame(
Fold = seq_len(L),
Intercepto = coeficientes[, 1],
Beta_vecinos = coeficientes[, 2]
)
knitr::kable(resultados_coef, digits = 5)| Fold | Intercepto | Beta_vecinos |
|---|---|---|
| 1 | -5.88753 | 2.23405 |
| 2 | -5.91567 | 2.27981 |
| 3 | -5.90189 | 2.24048 |
| 4 | -5.93580 | 2.28338 |
| 5 | -5.90893 | 2.27278 |
Comparamos ambos modelos mediante Brier score, log-loss y AUC, utilizando las mismas díadas de prueba.
# Función para calcular métricas probabilísticas
calcular_metricas <- function(y, p) {
# Evitar logaritmos de cero
p <- pmin(pmax(p, 1e-12), 1 - 1e-12)
# Calcular Brier score
brier <- mean((y - p)^2)
# Calcular pérdida logarítmica
logloss <- -mean(
y * log(p) + (1 - y) * log(1 - p)
)
# Devolver resultados
return(c(Brier = brier, LogLoss = logloss))
}
# Evaluar el modelo Bernoulli homogéneo
metricas_bern <- calcular_metricas(observado, prob_cv)
# Evaluar el modelo de vecinos comunes
metricas_vecinos <- calcular_metricas(observado, prob_vecinos)
# Comparar resultados
comparacion <- data.frame(
Modelo = c("Bernoulli homogéneo", "Vecinos comunes"),
Brier = c(metricas_bern[1], metricas_vecinos[1]),
LogLoss = c(metricas_bern[2], metricas_vecinos[2])
)
# Mostrar resultados
knitr::kable(comparacion, digits = 5)| Modelo | Brier | LogLoss |
|---|---|---|
| Bernoulli homogéneo | 0.01598 | 0.08304 |
| Vecinos comunes | 0.01093 | 0.04560 |
# Almacenar resultados por fold
auc_bern <- rep(NA_real_, L)
auc_vecinos <- rep(NA_real_, L)
# Evaluar ambos modelos
for (l in seq_len(L)) {
# Seleccionar las díadas de prueba
prueba <- fold == l
y <- observado[prueba]
# Verificar la presencia de ambas clases
if (length(unique(y)) == 2) {
# Curva ROC del Bernoulli homogéneo
roc_bern <- pROC::roc(
response = y,
predictor = prob_cv[prueba],
levels = c(0, 1),
direction = "<",
quiet = TRUE
)
# Curva ROC del modelo de vecinos comunes
roc_vecinos <- pROC::roc(
response = y,
predictor = prob_vecinos[prueba],
levels = c(0, 1),
direction = "<",
quiet = TRUE
)
# Guardar el AUC de cada modelo
auc_bern[l] <- as.numeric(pROC::auc(roc_bern))
auc_vecinos[l] <- as.numeric(pROC::auc(roc_vecinos))
}
}
# Comparar resultados por fold
resultados_auc <- data.frame(
Fold = seq_len(L),
Bernoulli = auc_bern,
Vecinos_comunes = auc_vecinos
)
# Mostrar resultados
knitr::kable(resultados_auc, digits = 4)| Fold | Bernoulli | Vecinos_comunes |
|---|---|---|
| 1 | 0.5 | 0.9391 |
| 2 | 0.5 | 0.9274 |
| 3 | 0.5 | 0.9260 |
| 4 | 0.5 | 0.9368 |
| 5 | 0.5 | 0.9328 |
# Resumir el AUC promedio
resumen_auc <- data.frame(
Modelo = c("Bernoulli homogéneo", "Vecinos comunes"),
AUC_promedio = c(
mean(auc_bern, na.rm = TRUE),
mean(auc_vecinos, na.rm = TRUE)
)
)
knitr::kable(resumen_auc, digits = 4)| Modelo | AUC_promedio |
|---|---|
| Bernoulli homogéneo | 0.5000 |
| Vecinos comunes | 0.9324 |
# Definir colores por modelo
color_vecinos <- adjustcolor("steelblue", alpha.f = 0.4)
color_bern <- adjustcolor("red3", alpha.f = 0.4)
# Inicializar el gráfico
primer_grafico <- TRUE
# Graficar las curvas ROC de todos los folds
for (l in seq_len(L)) {
# Seleccionar las díadas de prueba
prueba <- fold == l
y <- observado[prueba]
# Verificar que existen ambas clases
if (length(unique(y)) < 2) next
# Calcular ROC del modelo Bernoulli
roc_bern <- pROC::roc(
response = y,
predictor = prob_cv[prueba],
levels = c(0, 1),
direction = "<",
quiet = TRUE
)
# Calcular ROC del modelo de vecinos comunes
roc_vecinos <- pROC::roc(
response = y,
predictor = prob_vecinos[prueba],
levels = c(0, 1),
direction = "<",
quiet = TRUE
)
# Crear el gráfico con la primera curva
if (primer_grafico) {
plot(
roc_vecinos,
legacy.axes = TRUE,
col = color_vecinos,
lwd = 2,
xlab = "Tasa de falsos positivos",
ylab = "Sensibilidad",
main = "Curvas ROC por fold"
)
primer_grafico <- FALSE
} else {
# Agregar la curva de vecinos comunes
plot(
roc_vecinos,
add = TRUE,
col = color_vecinos,
lwd = 2
)
}
# Agregar la curva del modelo Bernoulli
plot(
roc_bern,
add = TRUE,
col = color_bern,
lwd = 2,
lty = 2
)
}
# Calcular el AUC promedio por modelo
auc_medio_vecinos <- mean(auc_vecinos, na.rm = TRUE)
auc_medio_bern <- mean(auc_bern, na.rm = TRUE)
# Agregar la leyenda
legend(
"bottomright",
legend = c(
paste0("Vecinos comunes: AUC = ", round(auc_medio_vecinos, 3)),
paste0("Bernoulli: AUC = ", round(auc_medio_bern, 3))
),
col = c("steelblue", "red3"),
lwd = 2,
lty = c(1, 2),
bty = "n"
)Además de los vecinos comunes, podemos considerar otros predictores para la red de aeropuertos:
Estos predictores pueden utilizarse en modelos de regresión logística para estimar probabilidades de enlace.
Los predictores estructurales deben calcularse exclusivamente con las aristas de entrenamiento.