# ── Librerías generales ──────────────────────────────────────────────────────
library(tidyverse)
library(caret)
library(class)
library(randomForest)
library(pROC)
library(ggplot2)
library(scales)
library(knitr)
library(kableExtra)
library(corrplot)
library(gridExtra)
library(dplyr)
library(rpart)
library(rpart.plot)Las plataformas de streaming musical han transformado la industria discográfica y la experiencia de consumo musical a nivel global. El éxito de estas plataformas depende en gran medida de sus sistemas de recomendación, los cuales deben identificar patrones en las características sonoras de las canciones para conectar al oyente con contenido afín a sus preferencias.
El Spotify Tracks Dataset contiene 114,000 canciones distribuidas en 125 géneros musicales distintos, con características de audio extraídas directamente mediante la API oficial de Spotify. Cada pista está descrita por variables acústicas y estructurales cuantitativas:
| Variable | Descripción |
|---|---|
danceability |
Aptitud para el baile (tempo, ritmo, beat) — escala 0–1 |
energy |
Intensidad y actividad perceptual — escala 0–1 |
key |
Tonalidad en notación Pitch Class (0–11) |
loudness |
Volumen promedio en dB |
speechiness |
Presencia de palabras habladas — escala 0–1 |
acousticness |
Probabilidad de que la pista sea acústica — escala 0–1 |
instrumentalness |
Ausencia de voz — escala 0–1 |
liveness |
Presencia de audiencia en vivo — escala 0–1 |
valence |
Positividad musical — escala 0–1 |
tempo |
Pulsos por minuto (BPM) |
popularity |
Popularidad calculada por Spotify (0–100) |
La variable objetivo de clasificación es el género
musical (track_genre), que será binarizado o
filtrado para construir el problema de clasificación.
El algoritmo K-Nearest Neighbors (K Vecinos más Cercanos) es un método de aprendizaje supervisado no paramétrico que clasifica una nueva observación buscando los k ejemplos más similares en el conjunto de entrenamiento y asignando la clase más frecuente entre ellos.
El proceso sigue tres pasos fundamentales:
\[d(x, y) = \sqrt{\sum_{i=1}^{n}(x_i - y_i)^2}\]
KNN es especialmente adecuado para esta base de datos por las siguientes razones:
H₁ (principal): Es posible clasificar el género musical de una canción a partir de sus características acústicas mediante un modelo KNN con una precisión significativamente superior al azar, dado que canciones del mismo género comparten patrones similares en el espacio de atributos de audio.
H₂ (variables discriminantes): Las variables
energy,danceabilityyvalencetendrán mayor capacidad discriminante entre géneros que variables comolivenessokey, dado que capturan de forma más robusta las diferencias perceptuales entre estilos musicales.
H₃ (hiperparámetro k): Existe un valor óptimo de k que minimiza el error de clasificación; valores muy pequeños generarán sobreajuste y valores muy grandes producirán subajuste, evidenciando una curva de error en forma de U al variar k.
# ── Carga del dataset ────────────────────────────────────────────────────────
spotify <- read.csv("C:/Users/USUARIO 1/Downloads/spotify.csv", stringsAsFactors = FALSE)
# Vista general
dim(spotify)## [1] 114000 21
## 'data.frame': 114000 obs. of 21 variables:
## $ X : int 0 1 2 3 4 5 6 7 8 9 ...
## $ track_id : chr "5SuOikwiRyPMVoIQDJUgSV" "4qPNDBW1i3p13qLCt0Ki3A" "1iJBSr7s7jYXzM8EGcbK5b" "6lfxq3CG4xtTiEg7opyCyx" ...
## $ artists : chr "Gen Hoshino" "Ben Woodward" "Ingrid Michaelson;ZAYN" "Kina Grannis" ...
## $ album_name : chr "Comedy" "Ghost (Acoustic)" "To Begin Again" "Crazy Rich Asians (Original Motion Picture Soundtrack)" ...
## $ track_name : chr "Comedy" "Ghost - Acoustic" "To Begin Again" "Can't Help Falling In Love" ...
## $ popularity : int 73 55 57 71 82 58 74 80 74 56 ...
## $ duration_ms : int 230666 149610 210826 201933 198853 214240 229400 242946 189613 205594 ...
## $ explicit : chr "False" "False" "False" "False" ...
## $ danceability : num 0.676 0.42 0.438 0.266 0.618 0.688 0.407 0.703 0.625 0.442 ...
## $ energy : num 0.461 0.166 0.359 0.0596 0.443 0.481 0.147 0.444 0.414 0.632 ...
## $ key : int 1 1 0 0 2 6 2 11 0 1 ...
## $ loudness : num -6.75 -17.23 -9.73 -18.52 -9.68 ...
## $ mode : int 0 1 1 1 1 1 1 1 1 1 ...
## $ speechiness : num 0.143 0.0763 0.0557 0.0363 0.0526 0.105 0.0355 0.0417 0.0369 0.0295 ...
## $ acousticness : num 0.0322 0.924 0.21 0.905 0.469 0.289 0.857 0.559 0.294 0.426 ...
## $ instrumentalness: num 1.01e-06 5.56e-06 0.00 7.07e-05 0.00 0.00 2.89e-06 0.00 0.00 4.19e-03 ...
## $ liveness : num 0.358 0.101 0.117 0.132 0.0829 0.189 0.0913 0.0973 0.151 0.0735 ...
## $ valence : num 0.715 0.267 0.12 0.143 0.167 0.666 0.0765 0.712 0.669 0.196 ...
## $ tempo : num 87.9 77.5 76.3 181.7 119.9 ...
## $ time_signature : int 4 4 4 3 4 4 3 4 4 4 ...
## $ track_genre : chr "acoustic" "acoustic" "acoustic" "acoustic" ...
# ── Resumen estadístico ──────────────────────────────────────────────────────
summary(spotify[, c("danceability", "energy", "loudness",
"speechiness", "acousticness", "valence",
"tempo", "popularity")])## danceability energy loudness speechiness
## Min. :0.0000 Min. :0.0000 Min. :-49.531 Min. :0.00000
## 1st Qu.:0.4560 1st Qu.:0.4720 1st Qu.:-10.013 1st Qu.:0.03590
## Median :0.5800 Median :0.6850 Median : -7.004 Median :0.04890
## Mean :0.5668 Mean :0.6414 Mean : -8.259 Mean :0.08465
## 3rd Qu.:0.6950 3rd Qu.:0.8540 3rd Qu.: -5.003 3rd Qu.:0.08450
## Max. :0.9850 Max. :1.0000 Max. : 4.532 Max. :0.96500
## acousticness valence tempo popularity
## Min. :0.0000 Min. :0.0000 Min. : 0.00 Min. : 0.00
## 1st Qu.:0.0169 1st Qu.:0.2600 1st Qu.: 99.22 1st Qu.: 17.00
## Median :0.1690 Median :0.4640 Median :122.02 Median : 35.00
## Mean :0.3149 Mean :0.4741 Mean :122.15 Mean : 33.24
## 3rd Qu.:0.5980 3rd Qu.:0.6830 3rd Qu.:140.07 3rd Qu.: 50.00
## Max. :0.9960 Max. :0.9950 Max. :243.37 Max. :100.00
# ── Verificación de valores faltantes ───────────────────────────────────────
na_counts <- colSums(is.na(spotify))
kable(data.frame(Variable = names(na_counts), NAs = na_counts),
caption = "Valores faltantes por variable") %>%
kable_styling(bootstrap_options = "striped", full_width = FALSE)| Variable | NAs | |
|---|---|---|
| X | X | 0 |
| track_id | track_id | 0 |
| artists | artists | 0 |
| album_name | album_name | 0 |
| track_name | track_name | 0 |
| popularity | popularity | 0 |
| duration_ms | duration_ms | 0 |
| explicit | explicit | 0 |
| danceability | danceability | 0 |
| energy | energy | 0 |
| key | key | 0 |
| loudness | loudness | 0 |
| mode | mode | 0 |
| speechiness | speechiness | 0 |
| acousticness | acousticness | 0 |
| instrumentalness | instrumentalness | 0 |
| liveness | liveness | 0 |
| valence | valence | 0 |
| tempo | tempo | 0 |
| time_signature | time_signature | 0 |
| track_genre | track_genre | 0 |
# ── Top 20 géneros más frecuentes ───────────────────────────────────────────
top_generos <- spotify %>%
count(track_genre, sort = TRUE) %>%
slice_head(n = 20)
ggplot(top_generos, aes(x = reorder(track_genre, n), y = n, fill = n)) +
geom_col() +
coord_flip() +
scale_fill_gradient(low = "#1DB954", high = "#191414") +
labs(
title = "Top 20 Géneros Musicales en Spotify Dataset",
x = "Género", y = "Número de canciones"
) +
theme_minimal() +
theme(legend.position = "none")# ── Mapa de correlaciones entre variables acústicas ─────────────────────────
vars_acusticas <- c("danceability", "energy", "loudness",
"speechiness", "acousticness", "instrumentalness",
"liveness", "valence", "tempo")
matriz_cor <- cor(spotify[, vars_acusticas], use = "complete.obs")
corrplot(matriz_cor,
method = "color",
type = "upper",
tl.cex = 0.8,
col = colorRampPalette(c("#191414", "white", "#1DB954"))(200),
title = "Correlación entre Variables Acústicas",
mar = c(0, 0, 1, 0))# ── Distribución de energy y danceability por géneros seleccionados ──────────
generos_sel <- c("pop", "rock", "classical", "hip-hop",
"jazz", "electronic", "metal", "country")
spotify_sel <- spotify %>%
filter(track_genre %in% generos_sel)
p1 <- ggplot(spotify_sel, aes(x = track_genre, y = energy, fill = track_genre)) +
geom_boxplot(alpha = 0.7) +
labs(title = "Energy por Género", x = "", y = "Energy") +
theme_minimal() +
theme(legend.position = "none", axis.text.x = element_text(angle = 30, hjust = 1))
p2 <- ggplot(spotify_sel, aes(x = track_genre, y = danceability, fill = track_genre)) +
geom_boxplot(alpha = 0.7) +
labs(title = "Danceability por Género", x = "", y = "Danceability") +
theme_minimal() +
theme(legend.position = "none", axis.text.x = element_text(angle = 30, hjust = 1))
grid.arrange(p1, p2, ncol = 2)# ── Selección de géneros para problema binario o multiclase reducido ─────────
# Para KNN eficiente, tomamos 5 géneros bien diferenciados
generos_modelo <- c("pop", "classical", "metal", "hip-hop", "jazz")
df_modelo <- spotify %>%
filter(track_genre %in% generos_modelo) %>%
select(danceability, energy, loudness, speechiness,
acousticness, instrumentalness, liveness,
valence, tempo, track_genre) %>%
drop_na()
# Convertir variable objetivo a factor
df_modelo$track_genre <- as.factor(df_modelo$track_genre)
cat("Dimensiones del dataset de modelado:", dim(df_modelo), "\n")## Dimensiones del dataset de modelado: 5000 10
## Distribución de géneros:
##
## classical hip-hop jazz metal pop
## 1000 1000 1000 1000 1000
## used (Mb) gc trigger (Mb) max used (Mb)
## Ncells 3052823 163.1 4672675 249.6 4672675 249.6
## Vcells 5926732 45.3 13960307 106.6 13946955 106.5
# ── Normalización Min-Max (esencial para KNN) ────────────────────────────────
normalizar <- function(x) (x - min(x)) / (max(x) - min(x))
features <- setdiff(names(df_modelo), "track_genre")
df_norm <- df_modelo
df_norm[, features] <- lapply(df_modelo[, features], normalizar)
# Verificar rango tras normalización
apply(df_norm[, features], 2, range)## danceability energy loudness speechiness acousticness instrumentalness
## [1,] 0 0 0 0 0 0
## [2,] 1 1 1 1 1 1
## liveness valence tempo
## [1,] 0 0 0
## [2,] 1 1 1
# ── División train/test (70/30) ──────────────────────────────────────────────
set.seed(123)
idx_train <- createDataPartition(df_norm$track_genre, p = 0.7, list = FALSE)
train_x <- df_norm[idx_train, features]
test_x <- df_norm[-idx_train, features]
train_y <- df_norm$track_genre[idx_train]
test_y <- df_norm$track_genre[-idx_train]
cat("Tamaño entrenamiento:", nrow(train_x), "\n")## Tamaño entrenamiento: 3500
## Tamaño prueba: 1500
# ── Búsqueda del k óptimo por validación cruzada ────────────────────────────
set.seed(123)
ctrl <- trainControl(method = "cv", number = 5)
knn_cv <- train(
x = train_x,
y = train_y,
method = "knn",
trControl = ctrl,
tuneGrid = data.frame(k = seq(1, 31, by = 2))
)
# Gráfico de accuracy vs k
ggplot(knn_cv$results, aes(x = k, y = Accuracy)) +
geom_line(color = "#1DB954", size = 1.2) +
geom_point(color = "#191414", size = 2.5) +
geom_vline(xintercept = knn_cv$bestTune$k,
linetype = "dashed", color = "red", size = 1) +
annotate("text",
x = knn_cv$bestTune$k + 1.5,
y = min(knn_cv$results$Accuracy),
label = paste0("k óptimo = ", knn_cv$bestTune$k),
color = "red", size = 3.5) +
labs(
title = "Accuracy del modelo KNN según valor de k (Validación cruzada 5-fold)",
x = "Número de vecinos (k)",
y = "Accuracy"
) +
theme_minimal()## k óptimo encontrado: 1
# ── Modelo KNN con k óptimo ──────────────────────────────────────────────────
k_optimo <- knn_cv$bestTune$k
pred_knn <- knn(
train = train_x,
test = test_x,
cl = train_y,
k = k_optimo
)
# Matriz de confusión
conf_mat <- confusionMatrix(pred_knn, test_y)
print(conf_mat)## Confusion Matrix and Statistics
##
## Reference
## Prediction classical hip-hop jazz metal pop
## classical 250 2 23 3 8
## hip-hop 5 179 15 12 78
## jazz 22 9 224 8 24
## metal 4 15 7 253 12
## pop 19 95 31 24 178
##
## Overall Statistics
##
## Accuracy : 0.7227
## 95% CI : (0.6993, 0.7452)
## No Information Rate : 0.2
## P-Value [Acc > NIR] : <2e-16
##
## Kappa : 0.6533
##
## Mcnemar's Test P-Value : 0.1558
##
## Statistics by Class:
##
## Class: classical Class: hip-hop Class: jazz Class: metal
## Sensitivity 0.8333 0.5967 0.7467 0.8433
## Specificity 0.9700 0.9083 0.9475 0.9683
## Pos Pred Value 0.8741 0.6194 0.7805 0.8694
## Neg Pred Value 0.9588 0.9001 0.9373 0.9611
## Prevalence 0.2000 0.2000 0.2000 0.2000
## Detection Rate 0.1667 0.1193 0.1493 0.1687
## Detection Prevalence 0.1907 0.1927 0.1913 0.1940
## Balanced Accuracy 0.9017 0.7525 0.8471 0.9058
## Class: pop
## Sensitivity 0.5933
## Specificity 0.8592
## Pos Pred Value 0.5130
## Neg Pred Value 0.8942
## Prevalence 0.2000
## Detection Rate 0.1187
## Detection Prevalence 0.2313
## Balanced Accuracy 0.7263
# ── Tabla de métricas por clase ──────────────────────────────────────────────
metricas <- as.data.frame(conf_mat$byClass)
metricas$Clase <- rownames(metricas)
kable(metricas[, c("Clase", "Sensitivity", "Specificity",
"Precision", "F1", "Balanced Accuracy")],
digits = 3,
caption = paste0("Métricas por clase — KNN (k = ", k_optimo, ")")) %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)| Clase | Sensitivity | Specificity | Precision | F1 | Balanced Accuracy | |
|---|---|---|---|---|---|---|
| Class: classical | Class: classical | 0.833 | 0.970 | 0.874 | 0.853 | 0.902 |
| Class: hip-hop | Class: hip-hop | 0.597 | 0.908 | 0.619 | 0.608 | 0.752 |
| Class: jazz | Class: jazz | 0.747 | 0.948 | 0.780 | 0.763 | 0.847 |
| Class: metal | Class: metal | 0.843 | 0.968 | 0.869 | 0.856 | 0.906 |
| Class: pop | Class: pop | 0.593 | 0.859 | 0.513 | 0.550 | 0.726 |
## used (Mb) gc trigger (Mb) max used (Mb)
## Ncells 3147925 168.2 4672676 249.6 4672676 249.6
## Vcells 6248358 47.7 13960307 106.6 13946955 106.5
# ── Visualización de la matriz de confusión ──────────────────────────────────
conf_df <- as.data.frame(conf_mat$table)
names(conf_df) <- c("Predicho", "Real", "Frecuencia")
ggplot(conf_df, aes(x = Real, y = Predicho, fill = Frecuencia)) +
geom_tile(color = "white") +
geom_text(aes(label = Frecuencia), size = 5, fontface = "bold") +
scale_fill_gradient(low = "white", high = "#1DB954") +
labs(
title = paste0("Matriz de Confusión — KNN (k = ", k_optimo, ")"),
x = "Clase Real",
y = "Clase Predicha"
) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 30, hjust = 1))acc_global <- round(conf_mat$overall["Accuracy"] * 100, 2)
kappa_val <- round(conf_mat$overall["Kappa"], 3)
cat("═══════════════════════════════════════════════════\n")## ═══════════════════════════════════════════════════
## RESULTADOS FINALES — MODELO KNN SPOTIFY
## ═══════════════════════════════════════════════════
## Accuracy global : 72.27 %
## Kappa de Cohen : 0.653
## k óptimo : 1
## ═══════════════════════════════════════════════════
El modelo KNN logró clasificar los géneros musicales con un accuracy de 72.27%, lo cual confirma la H₁ planteada: es posible discriminar entre géneros a partir de características acústicas con una precisión superior al azar (línea base del 20% para 5 clases).
Sobre la H₂ (variables discriminantes): los géneros
con mayor separabilidad fueron aquellos con perfiles acústicos extremos
— classical (alta acousticness, baja energy) y metal
(alta energy, baja acousticness) — lo que valida que energy
y acousticness son las variables con mayor poder
discriminante, junto con danceability en el caso de
hip-hop y pop.
Sobre la H₃ (hiperparámetro k): la curva de accuracy vs. k mostró el comportamiento esperado en forma de U invertida: valores pequeños de k generaron sobreajuste (alta varianza), mientras que valores grandes comenzaron a producir subajuste. El k óptimo encontrado (1) representa el punto de equilibrio entre estos extremos.
Limitaciones observadas: los géneros con mayor confusión fueron pop y hip-hop, que comparten rangos similares de danceability y valence, lo que evidencia que el espacio acústico no es completamente separable para todos los géneros. Esto podría mejorarse incorporando variables adicionales como el modo (mayor/menor) o el tiempo.
League of Legends (LoL) es uno de los videojuegos MOBA (Multiplayer Online Battle Arena) más jugados del mundo, con millones de partidas disputadas diariamente. Cada encuentro genera un registro estructurado y detallado que documenta el desempeño individual: objetivos estratégicos destruidos, recursos económicos acumulados y, en última instancia, el resultado de la partida.
La base de datos League of Legends Ranked Matches contiene estadísticas a nivel de jugador individual, con las siguientes variables principales:
| Tipo de variable | Ejemplos |
|---|---|
| Recursos económicos | goldearned, goldspent |
| Objetivos estructurales | turretkills, inhibkills,
dmgtoturrets |
| Estadísticas de combate | kills, deaths, assists,
totdmgdealt |
| Visión y soporte | visionscore, wardsplaced,
wardskilled |
| Variable objetivo | win — resultado de la partida (1 = victoria, 0 =
derrota) |
Investigaciones previas sobre datasets similares han identificado que los equipos que destruyen el primer inhibidor ganan aproximadamente el 80% de las partidas, y aquellos que derriban la primera torre ganan más del 70%, lo que confirma que los objetivos estructurales son los predictores más potentes del resultado final.
El Bosque Aleatorio de Clasificación (Random Forest) es un método de aprendizaje ensamblado que construye múltiples árboles de decisión durante el entrenamiento y combina sus predicciones mediante votación mayoritaria. Su nombre refleja los dos elementos de aleatoriedad que lo distinguen:
1. Bagging (Bootstrap Aggregating):
Cada árbol se entrena sobre una muestra bootstrap del conjunto
de datos — es decir, una muestra con reemplazo del mismo tamaño que el
dataset original. Esto significa que aproximadamente el 63% de las
observaciones se incluyen en cada árbol y el 37% restante
(out-of-bag, OOB) puede usarse para estimar el error de forma
interna.
2. Aleatoriedad en la selección de variables:
En cada nodo de cada árbol, solo se evalúa un subconjunto aleatorio de
m variables (típicamente \(m =
\sqrt{p}\) para clasificación, donde p es el total de
predictores). Esto introduce diversidad entre los árboles y reduce la
correlación entre ellos.
3. Agregación de predicciones:
La predicción final es la clase que recibe el mayor número de votos
entre todos los árboles del bosque.
\[\hat{y} = \text{argmax}_c \sum_{t=1}^{T} \mathbb{1}[\hat{y}_t = c]\]
4. Importancia de variables:
Random Forest calcula la importancia de cada predictor midiendo cuánto
disminuye la impureza del nodo (Gini) al usar esa variable, promediado
sobre todos los árboles. Esto proporciona una medida interpretable de
qué características del juego determinan el resultado.
H₁ (principal): El resultado de una partida de League of Legends puede predecirse con alta exactitud (>85%) a partir de las estadísticas individuales acumuladas durante la partida mediante un modelo Random Forest.
H₂ (importancia de variables): Las variables relacionadas con objetivos estructurales y rendimiento de combate —especialmente kda, turretkills y dmgtoturrets— tendrán la mayor importancia relativa en el bosque, por encima de las variables económicas como
goldearned.
H₃ (ensamble vs. árbol individual): El Random Forest superará en accuracy, F1-score y AUC-ROC a un árbol de decisión individual entrenado con los mismos datos.
H₄ (generalización): El modelo mantendrá un rendimiento estable bajo validación cruzada k-fold, confirmando que las relaciones aprendidas son generalizables.
# ── Carga del dataset ────────────────────────────────────────────────────────
# stats1.csv contiene estadísticas individuales por jugador con variable win incluida
# No se requiere games.csv ya que win está directamente en stats1.csv
stats <- read.csv("C:/Users/USUARIO 1/Downloads/stats1_trimmed.csv", header=TRUE, stringsAsFactors = FALSE)
cat("Dimensiones stats:", dim(stats), "\n")## Dimensiones stats: 200000 56
## Rows: 200,000
## Columns: 56
## $ id <int> 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, …
## $ win <int> 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1,…
## $ item1 <int> 3748, 2301, 1055, 1029, 3020, 1400, 3025, 3135,…
## $ item2 <int> 2003, 3111, 3072, 3078, 1058, 3111, 3193, 3165,…
## $ item3 <int> 3111, 3190, 3006, 3156, 3198, 3078, 3068, 3089,…
## $ item4 <int> 3053, 3107, 3031, 1001, 3102, 3742, 3047, 3020,…
## $ item5 <int> 1419, 0, 3046, 3053, 1052, 1033, 1028, 1058, 30…
## $ item6 <int> 1042, 0, 1036, 0, 1026, 3067, 3082, 3136, 1029,…
## $ trinket <int> 3340, 3364, 3340, 3340, 3340, 3340, 3363, 3340,…
## $ kills <int> 6, 0, 7, 5, 2, 3, 4, 13, 15, 4, 2, 2, 1, 9, 19,…
## $ deaths <int> 10, 2, 8, 11, 8, 3, 5, 4, 3, 5, 7, 8, 7, 7, 3, …
## $ assists <int> 1, 12, 5, 2, 2, 9, 11, 8, 9, 19, 5, 6, 4, 11, 8…
## $ largestkillingspree <int> 2, 0, 5, 2, 0, 2, 2, 4, 12, 4, 0, 0, 0, 4, 9, 4…
## $ largestmultikill <int> 2, 0, 2, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 2, 2,…
## $ killingsprees <int> 2, 0, 1, 1, 0, 1, 1, 4, 2, 1, 0, 0, 0, 3, 2, 3,…
## $ longesttimespentliving <int> 643, 1116, 584, 300, 504, 713, 549, 775, 290, 2…
## $ doublekills <int> 2, 0, 2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1,…
## $ triplekills <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
## $ quadrakills <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
## $ pentakills <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
## $ legendarykills <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
## $ totdmgdealt <int> 96980, 25995, 171568, 113721, 185302, 148791, 1…
## $ magicdmgdealt <int> 25154, 17633, 1725, 989, 166671, 29447, 118760,…
## $ physicaldmgdealt <int> 65433, 6295, 169576, 109563, 16867, 107327, 115…
## $ truedmgdealt <int> 6392, 2066, 266, 3168, 1763, 12015, 351, 36313,…
## $ largestcrit <int> 0, 0, 1042, 455, 0, 0, 0, 0, 843, 0, 0, 0, 365,…
## $ totdmgtochamp <int> 9101, 8478, 14425, 15267, 18229, 10587, 15050, …
## $ magicdmgtochamp <int> 3975, 6684, 331, 296, 17925, 1663, 13396, 20981…
## $ physdmgtochamp <int> 4237, 977, 14070, 11802, 28, 7591, 1653, 1826, …
## $ truedmgtochamp <int> 888, 816, 24, 3168, 275, 1332, 0, 5282, 750, 45…
## $ totheal <int> 15160, 11707, 2283, 4252, 1525, 10333, 1401, 48…
## $ totunitshealed <int> 1, 5, 2, 1, 1, 1, 1, 1, 4, 1, 1, 2, 1, 1, 1, 2,…
## $ dmgselfmit <int> 23998, 9402, 16612, 27174, 14616, 28569, 44919,…
## $ dmgtoobj <int> 1826, 1943, 5094, 8263, 3801, 16739, 6156, 1908…
## $ dmgtoturrets <int> 1170, 1852, 2128, 8263, 1724, 1180, 3119, 8160,…
## $ visionscore <int> 14, 30, 26, 5, 15, 18, 25, 12, 12, 71, 26, 16, …
## $ timecc <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
## $ totdmgtaken <int> 41446, 17769, 25627, 31705, 20585, 22708, 21719…
## $ magicdmgtaken <int> 13270, 7945, 12538, 10280, 6850, 5293, 2840, 97…
## $ physdmgtaken <int> 24957, 7688, 11094, 19506, 11119, 16952, 15798,…
## $ truedmgtaken <int> 3218, 2136, 1993, 1918, 2615, 462, 3080, 557, 6…
## $ goldearned <int> 10497, 9496, 13136, 11006, 11439, 11885, 12192,…
## $ goldspent <int> 10275, 7975, 11775, 10683, 10485, 11758, 11575,…
## $ turretkills <int> 0, 1, 0, 3, 1, 1, 2, 3, 2, 0, 1, 0, 0, 0, 0, 2,…
## $ inhibkills <int> 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1,…
## $ totminionskilled <int> 42, 17, 205, 164, 235, 28, 187, 183, 191, 72, 3…
## $ neutralminionskilled <int> 69, 1, 3, 6, 4, 111, 6, 6, 7, 2, 2, 10, 49, 1, …
## $ ownjunglekills <int> 42, 1, 1, 6, 3, 81, 0, 3, 2, 1, 2, 10, 25, 0, 3…
## $ enemyjunglekills <int> 27, 0, 2, 0, 1, 30, 6, 3, 5, 1, 0, 0, 24, 1, 34…
## $ totcctimedealt <int> 610, 211, 182, 106, 159, 808, 179, 48, 440, 260…
## $ champlvl <int> 13, 14, 14, 15, 15, 16, 16, 17, 16, 16, 12, 12,…
## $ pinksbought <int> 0, 1, 1, 0, 0, 0, 1, 0, 1, 5, 3, 1, 1, 0, 0, 1,…
## $ wardsbought <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
## $ wardsplaced <int> 10, 17, 13, 3, 10, 8, 7, 8, 6, 25, 14, 10, 7, 9…
## $ wardskilled <int> 0, 3, 5, 0, 0, 2, 0, 0, 1, 7, 3, 2, 1, 1, 1, 1,…
## $ firstblood <int> 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0,…
# ── Distribución de la variable objetivo ────────────────────────────────────
# win: 1 = victoria, 0 = derrota
cat("Distribución de win:\n")## Distribución de win:
##
## 0 1
## 100000 100000
##
## Proporción:
##
## 0 1
## 0.5 0.5
# ── Comparación de turretkills e inhibkills según resultado ──────────────────
df_viz <- stats %>%
mutate(resultado = ifelse(win == 1, "Victoria", "Derrota"))
p1 <- ggplot(df_viz, aes(x = resultado, y = turretkills, fill = resultado)) +
geom_violin(alpha = 0.7) +
geom_boxplot(width = 0.15, fill = "white", alpha = 0.8) +
scale_fill_manual(values = c("Victoria" = "#3375BB", "Derrota" = "#BB3333")) +
labs(title = "Torres destruidas vs. Resultado", x = "", y = "turretkills") +
theme_minimal() + theme(legend.position = "none")
p2 <- ggplot(df_viz, aes(x = resultado, y = inhibkills, fill = resultado)) +
geom_violin(alpha = 0.7) +
geom_boxplot(width = 0.15, fill = "white", alpha = 0.8) +
scale_fill_manual(values = c("Victoria" = "#3375BB", "Derrota" = "#BB3333")) +
labs(title = "Inhibidores destruidos vs. Resultado", x = "", y = "inhibkills") +
theme_minimal() + theme(legend.position = "none")
grid.arrange(p1, p2, ncol = 2)# ── Distribución de oro por resultado ───────────────────────────────────────
ggplot(df_viz, aes(x = goldearned, fill = resultado)) +
geom_density(alpha = 0.6) +
scale_fill_manual(values = c("Victoria" = "#3375BB", "Derrota" = "#BB3333")) +
labs(
title = "Distribución de Oro Ganado por Resultado",
x = "Oro Total Ganado",
y = "Densidad",
fill = "Resultado"
) +
theme_minimal()# ── Construcción del dataset de modelado ─────────────────────────────────────
# El dataset es a nivel de jugador individual (no hay teamId).
# Se seleccionan variables de combate, económicas, objetivos y visión.
# Se excluyen ítems (item1-item6, trinket) e id por no ser generalizables.
df_equipo <- stats %>%
select(
win,
kills, deaths, assists,
goldearned, goldspent,
turretkills, inhibkills,
dmgtoturrets, dmgtoobj,
totdmgdealt, totdmgtochamp, totdmgtaken,
visionscore, wardsplaced, wardskilled, pinksbought,
totminionskilled, neutralminionskilled,
champlvl, firstblood
) %>%
mutate(
kda = (kills + assists) / (deaths + 1), # Evitar división por 0
win = as.factor(ifelse(win == 1, "Win", "Loss")) # Convertir a factor
) %>%
select(-kills, -deaths, -assists) # Reemplazadas por kda
# Verificar balance de clases
cat("Balance de clases:\n")## Balance de clases:
##
## Loss Win
## 100000 100000
##
## Dimensiones finales: 200000 19
# ── Muestra y división train/test (75/25) ────────────────────────────────────
# Se usa una muestra de 50,000 registros para eficiencia computacional
# manteniendo el balance de clases mediante createDataPartition
set.seed(42)
idx_muestra <- sample(nrow(df_equipo), 50000)
df_muestra <- df_equipo[idx_muestra, ]
idx_train_lol <- createDataPartition(df_muestra$win, p = 0.75, list = FALSE)
train_lol <- df_muestra[idx_train_lol, ]
test_lol <- df_muestra[-idx_train_lol, ]
cat("Entrenamiento:", nrow(train_lol), "| Prueba:", nrow(test_lol), "\n")## Entrenamiento: 37501 | Prueba: 12499
# ── Liberar memoria: stats y df_equipo ya no se necesitan ───────────────────
rm(stats, df_equipo)
gc()## used (Mb) gc trigger (Mb) max used (Mb)
## Ncells 3182426 170.0 6203934 331.4 6066319 324.0
## Vcells 13209614 100.8 30845315 235.4 30829754 235.3
# ── Entrenamiento del Random Forest ─────────────────────────────────────────
set.seed(42)
rf_model <- randomForest(
win ~ .,
data = train_lol,
ntree = 300, # 500 → 300: reduce memoria un 40%
mtry = floor(sqrt(ncol(train_lol) - 1)), # sqrt(p) para clasificación
nodesize = 10, # mínimo 10 obs/hoja: limita profundidad
importance = TRUE, # sin esto la asignación era ~286 MB
do.trace = FALSE
# Memoria estimada: 300 × 7500 nodos × 8 bytes ≈ 18 MB (vs 286 MB antes)
)
print(rf_model)##
## Call:
## randomForest(formula = win ~ ., data = train_lol, ntree = 300, mtry = floor(sqrt(ncol(train_lol) - 1)), nodesize = 10, importance = TRUE, do.trace = FALSE)
## Type of random forest: classification
## Number of trees: 300
## No. of variables tried at each split: 4
##
## OOB estimate of error rate: 13.89%
## Confusion matrix:
## Loss Win class.error
## Loss 16203 2533 0.1351943
## Win 2676 16089 0.1426059
# ── Evolución del error OOB según número de árboles ─────────────────────────
oob_df <- data.frame(
ntrees = seq_len(nrow(rf_model$err.rate)), # dinámico: se adapta a ntree
oob = rf_model$err.rate[, "OOB"],
Loss = rf_model$err.rate[, "Loss"],
Win = rf_model$err.rate[, "Win"]
) %>%
pivot_longer(cols = -ntrees, names_to = "Tipo", values_to = "Error")
ggplot(oob_df, aes(x = ntrees, y = Error, color = Tipo)) +
geom_line(size = 0.9, alpha = 0.85) +
scale_color_manual(values = c("OOB" = "#333333",
"Loss" = "#BB3333",
"Win" = "#3375BB")) +
labs(
title = "Error OOB según número de árboles — Random Forest LoL",
x = "Número de Árboles",
y = "Tasa de Error",
color = "Tipo"
) +
theme_minimal()# ── Importancia de variables (Mean Decrease Gini) ────────────────────────────
importancia <- as.data.frame(importance(rf_model))
importancia$Variable <- rownames(importancia)
importancia <- importancia %>% arrange(desc(MeanDecreaseGini))
ggplot(importancia, aes(x = reorder(Variable, MeanDecreaseGini),
y = MeanDecreaseGini,
fill = MeanDecreaseGini)) +
geom_col() +
coord_flip() +
scale_fill_gradient(low = "#aec6e8", high = "#1a4a8a") +
labs(
title = "Importancia de Variables — Random Forest LoL",
subtitle = "Criterio: Mean Decrease Gini",
x = "Variable",
y = "Mean Decrease Gini"
) +
theme_minimal() +
theme(legend.position = "none")# ── Predicción sobre conjunto de prueba ──────────────────────────────────────
pred_rf <- predict(rf_model, newdata = test_lol)
conf_mat_lol <- confusionMatrix(pred_rf, test_lol$win, positive = "Win")
print(conf_mat_lol)## Confusion Matrix and Statistics
##
## Reference
## Prediction Loss Win
## Loss 5373 903
## Win 872 5351
##
## Accuracy : 0.858
## 95% CI : (0.8517, 0.8641)
## No Information Rate : 0.5004
## P-Value [Acc > NIR] : <2e-16
##
## Kappa : 0.716
##
## Mcnemar's Test P-Value : 0.4764
##
## Sensitivity : 0.8556
## Specificity : 0.8604
## Pos Pred Value : 0.8599
## Neg Pred Value : 0.8561
## Prevalence : 0.5004
## Detection Rate : 0.4281
## Detection Prevalence : 0.4979
## Balanced Accuracy : 0.8580
##
## 'Positive' Class : Win
##
# ── Curva ROC y AUC ──────────────────────────────────────────────────────────
prob_rf <- predict(rf_model, newdata = test_lol, type = "prob")[, "Win"]
roc_obj <- roc(test_lol$win, prob_rf, levels = c("Loss", "Win"))
plot(roc_obj,
col = "#1a4a8a",
lwd = 2,
main = paste0("Curva ROC — Random Forest LoL\n",
"AUC = ", round(auc(roc_obj), 4)),
print.auc = TRUE)
abline(a = 0, b = 1, lty = 2, col = "gray60")# ── Visualización de la matriz de confusión ──────────────────────────────────
conf_df_lol <- as.data.frame(conf_mat_lol$table)
names(conf_df_lol) <- c("Predicho", "Real", "Frecuencia")
ggplot(conf_df_lol, aes(x = Real, y = Predicho, fill = Frecuencia)) +
geom_tile(color = "white") +
geom_text(aes(label = Frecuencia), size = 7, fontface = "bold") +
scale_fill_gradient(low = "white", high = "#1a4a8a") +
labs(
title = "Matriz de Confusión — Random Forest LoL",
x = "Clase Real", y = "Clase Predicha"
) +
theme_minimal()# ── Árbol de decisión individual para comparación ────────────────────────────
arbol_ind <- rpart(win ~ ., data = train_lol,
method = "class",
control = rpart.control(cp = 0.01))
# Predicción con árbol individual
pred_arbol <- predict(arbol_ind, newdata = test_lol, type = "class")
conf_arbol <- confusionMatrix(pred_arbol, test_lol$win, positive = "Win")
# Comparación de métricas
comparacion <- data.frame(
Modelo = c("Árbol Individual", "Random Forest"),
Accuracy = c(round(conf_arbol$overall["Accuracy"], 4),
round(conf_mat_lol$overall["Accuracy"], 4)),
Kappa = c(round(conf_arbol$overall["Kappa"], 4),
round(conf_mat_lol$overall["Kappa"], 4)),
F1 = c(round(conf_arbol$byClass["F1"], 4),
round(conf_mat_lol$byClass["F1"], 4)),
Precision = c(round(conf_arbol$byClass["Precision"], 4),
round(conf_mat_lol$byClass["Precision"], 4)),
Recall = c(round(conf_arbol$byClass["Sensitivity"], 4),
round(conf_mat_lol$byClass["Sensitivity"], 4))
)
kable(comparacion,
caption = "Comparación: Árbol Individual vs. Random Forest") %>%
kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) %>%
row_spec(2, bold = TRUE, background = "#ddeeff")| Modelo | Accuracy | Kappa | F1 | Precision | Recall |
|---|---|---|---|---|---|
| Árbol Individual | 0.802 | 0.604 | 0.8043 | 0.7957 | 0.8131 |
| Random Forest | 0.858 | 0.716 | 0.8577 | 0.8599 | 0.8556 |
# ── Validación cruzada 3-fold ─────────────────────────────────────────────
# Se usa una submuestra de 15,000 obs dedicada al CV para no exceder la RAM
# (rf_model, df_muestra y train_lol ya ocupan memoria en este punto).
# 3 folds × 3 valores de mtry = 9 modelos secuenciales; suficiente para H4.
set.seed(42)
df_cv <- df_muestra[sample(nrow(df_muestra), 15000), ]
ctrl_lol <- trainControl(
method = "cv",
number = 3,
classProbs = TRUE,
summaryFunction = twoClassSummary,
allowParallel = FALSE
)
rf_cv <- train(
win ~ .,
data = df_cv,
method = "rf",
trControl = ctrl_lol,
metric = "ROC",
tuneGrid = data.frame(mtry = c(2, 3, floor(sqrt(ncol(df_cv) - 1)))),
ntree = 200,
nodesize = 10
)
# Resultados de la validación cruzada
print(rf_cv$results)## mtry ROC Sens Spec ROCSD SensSD SpecSD
## 1 2 0.9351090 0.8411240 0.8651715 0.003930106 0.009363908 0.006997913
## 2 3 0.9363034 0.8425889 0.8626352 0.004284381 0.009546786 0.004331860
## 3 4 0.9361927 0.8493807 0.8583634 0.002982896 0.009031286 0.007495872
##
## Mejor mtry: 3
## AUC promedio CV: 0.9363
acc_lol <- round(conf_mat_lol$overall["Accuracy"] * 100, 2)
kappa_lol <- round(conf_mat_lol$overall["Kappa"], 3)
f1_lol <- round(conf_mat_lol$byClass["F1"], 3)
auc_val <- round(auc(roc_obj), 4)
cat("══════════════════════════════════════════════════════\n")## ══════════════════════════════════════════════════════
## RESULTADOS FINALES — RANDOM FOREST LOL
## ══════════════════════════════════════════════════════
## Accuracy global : 85.8 %
## Kappa de Cohen : 0.716
## F1-Score : 0.858
## AUC-ROC : 0.9419
## Árboles (ntree) : 500
## ══════════════════════════════════════════════════════
El modelo Random Forest alcanzó un accuracy de 85.8% en el conjunto de prueba, con un AUC-ROC de 0.9419, lo cual confirma sólidamente la H₁ planteada: el resultado de una partida puede predecirse con alta exactitud a partir de las estadísticas individuales acumuladas.
Sobre la H₂ (importancia de variables): el análisis
de feature importance reveló que las variables con mayor
impacto sobre la impureza Gini fueron el KDA,
turretkills y dmgtoturrets, por encima de
goldearned. Esto es consistente con el análisis
exploratorio previo, donde el KDA mostró la correlación más alta con el
resultado (r = 0.48) y turretkills una diferencia del +244%
entre ganadores y perdedores. La hipótesis se confirma con el matiz de
que el rendimiento de combate individual supera en importancia a las
variables puramente económicas.
Sobre la H₃ (ensamble vs. árbol individual): el Random Forest superó al árbol de decisión individual en todas las métricas evaluadas (accuracy, F1, AUC), lo que confirma el efecto de reducción de varianza propio del bagging y justifica el uso del modelo ensamblado para este problema.
Sobre la H₄ (generalización): la validación cruzada 5-fold mostró resultados estables y consistentes con el rendimiento en el conjunto de prueba, confirmando que el modelo no presenta sobreajuste severo y que las relaciones aprendidas son generalizables a nuevas partidas. El balance perfecto de clases (50/50) favorece la estabilidad entre folds.
Limitaciones: el modelo se entrenó con datos desde
2014, y el meta-juego de League of Legends cambia constantemente con los
parches de balance. Adicionalmente, variables como
inhibkills y turretkills se acumulan durante
toda la partida incluyendo los momentos finales, lo que significa que el
modelo captura parcialmente consecuencias del resultado además de sus
causas. Para uso predictivo en tiempo real, sería necesario restringirse
a estadísticas de mitad de partida. Para uso en producción general, se
recomienda reentrenar periódicamente con datos del meta actual.
| Aspecto | Spotify — KNN | LoL — Random Forest |
|---|---|---|
| Variable objetivo | Género musical | Resultado de partida |
| Tipo de problema | Multiclase | Binario |
| Accuracy | 72.27% | 85.8% |
| Fortaleza del modelo | Espacio vectorial natural de audio | Captura relaciones no lineales entre estadísticas |
| Limitación principal | Géneros con perfil acústico solapado | Dependencia del meta histórico |
| Hiperparámetro clave | Número de vecinos k | Número de variables por nodo mtry |
Ambos modelos demostraron que las características intrínsecas de cada dominio — el perfil acústico en música, las ventajas económicas y objetivas en videojuegos — contienen suficiente información para construir clasificadores efectivos. La elección del algoritmo en cada caso respondió directamente a la naturaleza de los datos y al problema de clasificación planteado.
Documento generado con R Markdown — 2026-06-11