La clasificación supervisada es una de las técnicas más importantes dentro del aprendizaje automático, con aplicaciones prácticas en diversas áreas como la banca, medicina, mercadeo, entre otras. Uno de los métodos más intuitivos, sencillos y efectivos para la clasificación supervisada es el algoritmo K-Vecinos más Cercanos (K-NN).
El método K-NN clasifica observaciones nuevas según la clase predominante entre sus vecinos más cercanos, basándose en la proximidad definida mediante una distancia, generalmente euclidiana. La popularidad del método radica en su simplicidad conceptual y su facilidad de implementación.
En esta clase aprenderemos en detalle cómo funciona el método K-NN, cómo implementarlo en R paso a paso y cómo interpretar sus resultados en contextos prácticos.
El primer paso consiste en preparar cuidadosamente el conjunto de datos:
Cargar los datos: importa el conjunto de datos y verificar su estructura (variables predictoras y variable respuesta).
Explorar los datos: revisa la distribución de las clases y posibles problemas como valores atípicos o faltantes.
Normalizar los datos: dado que K-NN utiliza distancias, es importante estandarizar las variables predictoras para evitar sesgos debido a diferencias en escalas.
Divide el conjunto total en dos subconjuntos de forma aleatoria:
Un conjunto de entrenamiento (aproximadamente 70-80%), que se usa para ajustar el modelo.
Un conjunto de prueba (20-30%) que no se usará en la construcción inicial del modelo y servirá para evaluar el desempeño del mismo.
La división del conjunto de datos en entrenamiento y prueba puede
realizarse fácilmente en R utilizando la función
createFolds()
del paquete caret
,
que permite crear particiones aleatorias y balanceadas de los datos.
La lógica del algoritmo K-NN se estructura en tres etapas claras y sucesivas:
El método comienza calculando la distancia entre la observación nueva (por clasificar) y cada una de las observaciones del conjunto de entrenamiento. Generalmente se utiliza la distancia euclidiana, definida como: \[ d(x,y) = \sqrt{\sum_{i=1}^p (x_i-y_i)^2} \] donde \(x\) y \(y\) son observaciones, y \(p\) es el número de variables predictoras.
La distancia puede cambiarse dependiendo del problema (por ejemplo, distancia de Manhattan o Minkowski), pero la euclidiana es la más frecuente.
Luego de calcular las distancias, el algoritmo selecciona las \(K\) observaciones del conjunto de entrenamiento que tienen las menores distancias con respecto a la nueva observación.
La elección del valor \(k\) es crucial:
Para elegir un valor adecuado de \(k\), puedes utilizar la función
train.kknn()
de la librería kknn
,
la cual automáticamente evalúa distintos valores de \(K\) mediante validación cruzada,
facilitando así seleccionar aquel que ofrezca el mejor rendimiento del
modelo.
Finalmente, asignamos la clase que más se repite entre los \(K\) vecinos seleccionados.
La clase predominante se asigna a la observación nueva. Si existiera empate, generalmente se rompe de manera aleatoria o se prueba con otro valor de \(k\). Para evitar esta situación, se recomienda utilizar un valor impar de \(k\).
Para validar el modelo se usa Validación Cruzada (K-fold Cross-validation) Ahora, realiza una validación cruzada K-fold para comprobar la estabilidad del modelo. La forma más usual que se hace es:
Calcula el rendimiento promedio en términos de las métricas anteriores.
¿Por qué es importante validar el modelo con validación cruzada?
Este procedimiento asegura que el desempeño del modelo no dependa exclusivamente de una sola división específica y proporciona una medida más confiable y robusta del desempeño general del algoritmo.
Finalmente, debes analizar la estabilidad y calidad de las predicciones del modelo, considerando lo siguiente:
Considera el siguiente conjunto sintético de datos, donde existen dos grupos claramente diferenciados (representados por puntos rojos y azules). Supón que queremos clasificar la nueva observación indicada por el triángulo verde:
¿Cómo funciona el método K-NN aquí?
Calcular la distancia: primero, calculamos la distancia desde el nuevo punto verde hacia cada uno de los puntos rojos y azules (datos ya clasificados previamente).
Seleccionamos los vecinos más cercanos: imagina que elegimos \(K=4\). El círculo alrededor del triángulo verde indica que dentro de esa región están los 4 puntos más cercanos a la observación que queremos clasificar.
Clasificar según la mayoría: dentro del círculo observamos claramente que hay más puntos rojos (3 puntos rojos frente a 1 azules). Por lo tanto, la observación verde sería clasificada en la clase representada por los puntos rojos.
Iniciamos con un ejemplo sencillo de clasificación con datos inventados: Supongamos que tenemos las calificaciones de una materia cualquiera, donde la calificación depende tres actividades:
trabajo <- c(100,40,60,70,70,60,80,90,20,50,60,50,30,20,20,10,80,90,20,70)
examen <- c(90,50,60,70,80,70,60,95,10,55,70,60,20,10,50,55,90,100,40,60)
participacion <- c(1,2,1,1,1,2,2,1,3,3,3,2,3,3,2,2,1,1,3,3)
tabla <- data.frame(trabajo,examen,participacion)
Preparación inicial y limpieza de los datos:
Supongamos que deseamos ver que tan buena es la apreciación del maestro colocando la calificación de participación en clase. Para ello, tomaremos esta variable como la variable categórica. Observemos en la siguiente gráfica los datos.
plot(tabla[,1:2],main="Relación entre trabajo en clase y Examen",xlab="Trabajo en clase", ylab="Examen ",col=tabla$participacion,pch=19)
legend("topright",legend=c("1","2","3"),pch=19,col=c(1,2,3))
Para estos datos la exploración, limpieza y normalización de los datos no es necesaria.
Dividir los datos en conjunto de entrenamiento y prueba
Para este ejemplo, todos nuestros datos serán el conjunto de entrenamiento y supondremos nuevos datos para el conjunto de prueba. Por ejemplo, supongamos las siguientes dos nuevas observaciones.
nuevos <- data.frame(trabajo=c(20,90),examen=c(30,80))
Estos datos los podemos apreciar de forma gráfica en color azul en la siguiente figura.
plot(tabla[,1:2],xlab="Trabajo en clase", ylab="Examen 1",col=tabla$participacion,pch=19)
legend("topright",legend=c("1","2","3"),pch=19,col=c(1,2,3))
points(nuevos,col="blue",pch=19,lwd=2)
Aplicar el método K-NN
Construimos el modelo ajustado con los datos de entrenamiento. Para
esto trabajaremos con la librería
class
, donde se utiliza la función
knn()
.
library(class)
modelo <- knn(train = tabla[,-3], test=nuevos, cl = tabla$participacion, k=3)
modelo
## [1] 3 1
## Levels: 1 2 3
Validar la estabilidad del modelo
Al ser un ejemplo sencillo no se realizó este paso.
Interpretación de los resultados finales
De esta manera se dice que el primer nuevo alumno deberá ser clasificado con participación 3, mientras que el segundo alumno deberá ser clasificado como participación 1. En este paquete se debe jugar a conseguir el numero de vecinos óptimo de forma manual, o se necesitaría primero calcular el valor óptimo y luego aplicar el modelo. Como ejemplo lo podemos ahora calcular con \(k=4\).
modelo1 <-knn(train = tabla[,-3], test=nuevos, cl = tabla$participacion, k=4)
modelo1
## [1] 3 1
## Levels: 1 2 3
Notemos que los resultados son estables al número de vecinos más cercano que se tome.
Para este segundo ejemplo, trabajaremos con un conjunto de datos iris.
Preparación inicial y limpieza de los datos:
Para estos datos la exploración, limpieza y normalización de los datos no es necesaria, pero cuando se tengan datos reales es muy importante realizarla.
data <-iris
head(data)
Sepal.Length | Sepal.Width | Petal.Length | Petal.Width | Species |
---|---|---|---|---|
5.1 | 3.5 | 1.4 | 0.2 | setosa |
4.9 | 3.0 | 1.4 | 0.2 | setosa |
4.7 | 3.2 | 1.3 | 0.2 | setosa |
4.6 | 3.1 | 1.5 | 0.2 | setosa |
5.0 | 3.6 | 1.4 | 0.2 | setosa |
5.4 | 3.9 | 1.7 | 0.4 | setosa |
Dividir los datos en conjunto de entrenamiento y prueba
Mediante un muestreo aleatorio, separamos el conjunto de entrenamiento y en conjunto de prueba. Supongamos en 5 grupos (folds=5), 4 para entrenamiento y 1 para prueba.
set.seed(2020)
library(caret)
folds <- createFolds(iris$Species, k = 5)
entrenamiento <- iris[c(folds$Fold1,folds$Fold2,folds$Fold3,folds$Fold4),]
prueba <- iris[folds$Fold5,]
dim(entrenamiento)[1]
## [1] 120
dim(prueba)[1]
## [1] 30
Aplicar el método K-NN
Construimos el modelo ajustado con los datos de entrenamiento. Para
este ejemplo utilizaremos la libreria
kknn
. En esta paquetería se usa
la función rain.kknn()
, a la cual se le debe indicar el
valor máximo de \(k\) y ella determina
el valor óptimo.
library(kknn)
modelo <- train.kknn(Species ~ ., data = entrenamiento, kmax = 8)
modelo
##
## Call:
## train.kknn(formula = Species ~ ., data = entrenamiento, kmax = 8)
##
## Type of response variable: nominal
## Minimal misclassification: 0.05833333
## Best kernel: optimal
## Best k: 5
Notemos que encuentra como valor óptimo \(k=5\) vecinos. Podemos correr el modelo con los datos de entrenamiento para revisar el margen de error.
Pres <- predict(modelo, entrenamiento[,-5])
tt <- table(entrenamiento[,5],Pres)
tt
/Pres | setosa | versicolor | virginica |
---|---|---|---|
setosa | 40 | 0 | 0 |
versicolor | 0 | 39 | 1 |
virginica | 0 | 1 | 39 |
Validar la estabilidad del modelo
No se aplico en este ejemplo, pero se podría hacer la validación haciendo 5 replicas del proceso, tomando en cada iteración como conjunto de entrenamiento cada una de las particiones creadas.
Interpretación de los resultados finales
Podemos observar en la tabla, en las filas la clasificación real y en las columnas la predicción. La tasa de aciertos del modelo respecto a las observaciones de entrenamiento sería:
TA <- (sum(diag(tt)))/sum(tt)
round(TA,2)
## [1] 0.98
Podemos hablar de una precisión del \(98\%\) o de un error del \(2\%\). Para analizar la calidad del modelo, se podrían construir las curvas ROC aprendidas en la sección anterior. Ahora, veamos que tan buena es la predicción para el conjunto de prueba.
Pred <- predict(modelo, prueba[,-5])
table <- table(prueba[,5],Pred)
table
/Pred | setosa | versicolor | virginica |
---|---|---|---|
setosa | 10 | 0 | 0 |
versicolor | 0 | 8 | 2 |
virginica | 0 | 0 | 10 |
clas <- (sum(diag(table)))/sum(table)
clas
## [1] 0.9333333
Encontramos que el modelo logra predecir un \(93\%\) los datos de prueba.
Para este último ejemplo, trabajaremos con una base de datos de biopsias que se encuentra en el sitio web de la editorial O’Reilly . Esta base de datos contiene 569 observaciones y 32 variables relativas a las propiedades relacionadas con biopsias de tumores, calificados como “M” (maligno) o “B” (benigno).
# Librerías necesarias
library(tidyverse)
library(caret)
library(kknn)
library(scales)
# Cargar datos desde la web (O'Reilly)
url <- "https://raw.githubusercontent.com/stedy/Machine-Learning-with-R-datasets/master/wisc_bc_data.csv"
data <- read.csv(url)
Preparación inicial y limpieza de los datos:
Eliminamos la variable id
, ya que puede dar lugar a un
sobreajuste ya que identifica cada observación de forma única.
# Eliminar columna id
data <- data %>% select(-id)
# Modificamos los nombres de los valores de la variable diagnosis para que sean más claros
data <- mutate(data,diagnosis = fct_recode(data$diagnosis,
"Bening" = "B","Malignant" = "M"))
Exploramos la cantidad de tumores benignos y malignos de la base de datos.
table(data$diagnosis)
Bening | Malignant |
---|---|
357 | 212 |
round(prop.table(table(data$diagnosis)),2)
Bening | Malignant |
---|---|
0.63 | 0.37 |
Note que las variables manejan magnitudes muy diversas, por lo cual
algunas variables cuya magnitud de medida sea más grande que otras,
tendrán mayor peso a la hora de calcular la distancia entre vecinos. Al
ser ello un problema para el clasificador, reescalamos todas las
variables a un rango estándar de valores. Es decir, podemos pensar en
volverlas todas proporciones entre 0 y 1. Esto se puede hacer usando la
función rescale()
que usa la función min-max.
# Normalización Min-Max con rescale()
data_norm <- data %>% select(-diagnosis) %>%
mutate(across(everything(), rescale))
Dividir los datos en conjunto de entrenamiento y prueba
Dividimos la muestra en dos conjuntos, uno para entrenamiento y otro
para prueba. Para ello, Utilizaremos createFolds()
de caret
para garantizar balance de clases en ambos conjuntos.
set.seed(2025)
folds <- createFolds(data$diagnosis, k = 6)
entrenamiento <- data_norm[-folds[[6]],]
prueba <- data_norm[folds[[6]],]
Por otra parte, guardamos las etiquetas del diagnóstico de todas las observaciones en dos vectores por separado.
# Etiquetas
entrenamiento_labels <- data$diagnosis[-folds[[6]]]
prueba_labels <- data$diagnosis[folds[[6]]]
Aplicar el método K-NN
train.kknn(entrenamiento_labels ~ ., data = entrenamiento, kmax = 50)
##
## Call:
## train.kknn(formula = entrenamiento_labels ~ ., data = entrenamiento, kmax = 50)
##
## Type of response variable: nominal
## Minimal misclassification: 0.03164557
## Best kernel: optimal
## Best k: 19
Encontramos que el valor optimo de \(k=19\). Entonces la predicción, sería:
pred <- knn(entrenamiento,prueba, cl = entrenamiento_labels, k = 19)
confusionMatrix(data = pred, reference = prueba_labels)
## Confusion Matrix and Statistics
##
## Reference
## Prediction Bening Malignant
## Bening 60 2
## Malignant 0 33
##
## Accuracy : 0.9789
## 95% CI : (0.926, 0.9974)
## No Information Rate : 0.6316
## P-Value [Acc > NIR] : <2e-16
##
## Kappa : 0.9542
##
## Mcnemar's Test P-Value : 0.4795
##
## Sensitivity : 1.0000
## Specificity : 0.9429
## Pos Pred Value : 0.9677
## Neg Pred Value : 1.0000
## Prevalence : 0.6316
## Detection Rate : 0.6316
## Detection Prevalence : 0.6526
## Balanced Accuracy : 0.9714
##
## 'Positive' Class : Bening
##
Con este modelo hemos obtenido una exactitud del \(97.89\%\) a la hora de acertar en una
predicción. Probemos ahora con reescalando los valores con la función
scale()
, la cual convierte las variables en normales estándar, y quizás
nos pueda ayudar a mejorar la clasificación.
data_z <- as.data.frame(scale(data[,-1]))
entrenamiento_z <- data_z[-folds[[6]],]
prueba_z <- data_z[folds[[6]],]
pred_z <- knn(entrenamiento_z, prueba_z, cl = entrenamiento_labels, k = 19)
confusionMatrix(data = pred_z, reference = prueba_labels)
## Confusion Matrix and Statistics
##
## Reference
## Prediction Bening Malignant
## Bening 60 3
## Malignant 0 32
##
## Accuracy : 0.9684
## 95% CI : (0.9105, 0.9934)
## No Information Rate : 0.6316
## P-Value [Acc > NIR] : 3.19e-15
##
## Kappa : 0.9309
##
## Mcnemar's Test P-Value : 0.2482
##
## Sensitivity : 1.0000
## Specificity : 0.9143
## Pos Pred Value : 0.9524
## Neg Pred Value : 1.0000
## Prevalence : 0.6316
## Detection Rate : 0.6316
## Detection Prevalence : 0.6632
## Balanced Accuracy : 0.9571
##
## 'Positive' Class : Bening
##
En este caso podemos ver que nuestra exactitud bajo a aproximadamente \(96.84\%\), por lo que no hemos conseguido mejorarla con respecto a la normalización min-max.
Validar la estabilidad del modelo
Apliquemos validación cruzada para validar la estabilidad del modelo. La idea es analizar que la tasa de aciertos conseguida no dependa de la partición utilizada.
Podemos implementar manualmente la validación cruzada, usando en cada iteración un fold diferente como prueba y los demás como entrenamiento.
# Guardar la exactitud de cada fold
exactitud <- numeric(length = 6)
for(i in 1:6){
# Definir conjuntos entrenamiento y prueba según el fold actual
prueba <- data_norm[folds[[i]],]
entrenamiento <- data_norm[-folds[[i]],]
# Etiquetas
entrenamiento_labels <- data$diagnosis[-folds[[i]]]
prueba_labels <- data$diagnosis[folds[[i]]]
pred_knn <- knn(entrenamiento,prueba, cl= entrenamiento_labels, k = 19)
# Evaluar exactitud del modelo en cada fold
cm <- confusionMatrix(pred_knn, prueba_labels)
exactitud[i] <- cm$overall["Accuracy"]
# Mostrar resultado del fold
cat("Fold", i, "- Exactitud:", exactitud[i], "\n")
}
## Fold 1 - Exactitud: 0.9361702
## Fold 2 - Exactitud: 0.9787234
## Fold 3 - Exactitud: 0.9684211
## Fold 4 - Exactitud: 0.9578947
## Fold 5 - Exactitud: 0.9791667
## Fold 6 - Exactitud: 0.9789474
# Exactitud promedio de validación cruzada
Exactitud_promedio <- round(mean(exactitud),4)*100
paste("Exactitud_promedio: ",Exactitud_promedio,"%",sep="")
## [1] "Exactitud_promedio: 96.66%"
Interpretación de los resultados finales
También podemos hacer la validación cruzada de forma más automática y tomando posiblemente más folds.
set.seed(2025)
train_control <- trainControl(method="cv",number=10,savePredictions = TRUE)
knn_cv <- train(diagnosis ~ ., data=cbind(prueba, diagnosis=prueba_labels),
method = "knn", trControl = train_control, tuneGrid = data.frame(k=19))
# Resultados de validación cruzada
knn_cv
## k-Nearest Neighbors
##
## 95 samples
## 30 predictors
## 2 classes: 'Bening', 'Malignant'
##
## No pre-processing
## Resampling: Cross-Validated (10 fold)
## Summary of sample sizes: 85, 85, 85, 86, 86, 86, ...
## Resampling results:
##
## Accuracy Kappa
## 0.9577778 0.9019763
##
## Tuning parameter 'k' was held constant at a value of 19
# Matriz de confusión usando predicciones guardadas por caret
confusionMatrix(knn_cv$pred$pred, knn_cv$pred$obs)
## Confusion Matrix and Statistics
##
## Reference
## Prediction Bening Malignant
## Bening 60 4
## Malignant 0 31
##
## Accuracy : 0.9579
## 95% CI : (0.8957, 0.9884)
## No Information Rate : 0.6316
## P-Value [Acc > NIR] : 4.367e-14
##
## Kappa : 0.9073
##
## Mcnemar's Test P-Value : 0.1336
##
## Sensitivity : 1.0000
## Specificity : 0.8857
## Pos Pred Value : 0.9375
## Neg Pred Value : 1.0000
## Prevalence : 0.6316
## Detection Rate : 0.6316
## Detection Prevalence : 0.6737
## Balanced Accuracy : 0.9429
##
## 'Positive' Class : Bening
##
Podemos concluir que la tasa de aciertos es aproximadamente \(95.79%\) y es bastante robusta. Es decir, aunque se muevan un poco los parámetros la tasa se mantienes constante.
A continuación se presentan dos conjuntos datos reales.
Este conjunto de datos contiene información clínica de pacientes, con el objetivo de predecir la mortalidad por insuficiencia cardíaca.
Este conjunto de datos está disponible en el Repositorio de Machine Learning de la UCI.
Este conjunto de datos incluye variables físico-químicas de muestras de vino, con el objetivo de predecir la calidad realizada por expertos.
Este conjunto de datos está disponible en el Repositorio de Machine Learning de la UCI.
Aplique la metodología aprendida en clase a estos conjuntos de datos.