Support Vector Machine (SVM)

Author

Dante Conti, Sergi Ramirez, (c) IDEAI

1 1. Objetivo del notebook

Este notebook construye modelos de Support Vector Machine (SVM) para clasificación binaria, mostrando la implementación en R y en Python dentro de cada apartado.

La idea no es tener una pestaña global de R y otra de Python, sino que cada bloque del flujo tenga sus propias pestañas:

  • preparación de datos,
  • exploración,
  • partición train/test,
  • SVM lineal,
  • SVM radial,
  • SVM polinomial,
  • evaluación,
  • visualización de fronteras,
  • predicción de un nuevo registro.

Además, después de cada bloque se incluye una interpretación para entender qué significa cada salida.

2 2. Paquetes necesarios

Mostrar código
paquetes <- c(
  "caret", "kernlab", "e1071", "dplyr", "ggplot2", "pROC",
  "yardstick", "tibble", "tidyr", "purrr"
)

instalar_si_falta <- function(pkg) {
  if (!requireNamespace(pkg, quietly = TRUE)) {
    install.packages(pkg, dependencies = TRUE)
  }
}

invisible(lapply(paquetes, instalar_si_falta))

library(caret)
library(kernlab)
library(e1071)
library(dplyr)
library(ggplot2)
library(pROC)
library(yardstick)
library(tibble)
library(tidyr)
library(purrr)

set.seed(123)
Mostrar código
import sys
import subprocess
import importlib.util

paquetes = ["numpy", "pandas", "matplotlib", "sklearn"]

for pkg in paquetes:
    if importlib.util.find_spec(pkg) is None:
        subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.datasets import make_classification, make_circles
from sklearn.model_selection import train_test_split, GridSearchCV, StratifiedKFold
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.svm import SVC
from sklearn.metrics import (
    confusion_matrix, classification_report, accuracy_score,
    precision_score, recall_score, f1_score, roc_auc_score,
    RocCurveDisplay
)

np.random.seed(123)

Interpretación.
Aquí solo se cargan las librerías. En R se usa principalmente caret, que permite entrenar modelos con validación cruzada de forma muy cómoda. En Python se usa scikit-learn, que es el estándar para este tipo de modelos. En ambos casos es importante usar set.seed() o np.random.seed() para que los resultados sean reproducibles.

3 3. Carga y preparación de datos

En este notebook se usan datos simulados de riesgo de crédito. Esto hace que el documento sea totalmente ejecutable sin depender de ningún CSV externo.

La variable objetivo es binaria:

  • Bad: cliente de mayor riesgo.
  • Good: cliente de menor riesgo.
Mostrar código
set.seed(123)

n <- 1000

datos <- tibble(
  edad = round(rnorm(n, mean = 42, sd = 12)),
  ingresos = round(rlnorm(n, meanlog = 10.4, sdlog = 0.45)),
  deuda = round(rlnorm(n, meanlog = 8.7, sdlog = 0.65)),
  antiguedad_laboral = pmax(0, round(rnorm(n, mean = 7, sd = 5))),
  num_productos = sample(1:5, n, replace = TRUE),
  historial = sample(c("Bueno", "Regular", "Malo"), n, replace = TRUE, prob = c(0.55, 0.30, 0.15)),
  vivienda = sample(c("Propia", "Alquiler", "Familia"), n, replace = TRUE, prob = c(0.45, 0.40, 0.15))
)

score_riesgo <-
  -0.000035 * datos$ingresos +
  0.00018 * datos$deuda -
  0.08 * datos$antiguedad_laboral +
  0.23 * datos$num_productos +
  ifelse(datos$historial == "Malo", 1.4, ifelse(datos$historial == "Regular", 0.55, -0.45)) +
  ifelse(datos$vivienda == "Alquiler", 0.25, 0) +
  rnorm(n, 0, 0.9)

prob_bad <- 1 / (1 + exp(-score_riesgo))
datos$Class <- factor(ifelse(runif(n) < prob_bad, "Bad", "Good"), levels = c("Bad", "Good"))

objetivo <- "Class"

datos <- datos %>%
  mutate(
    historial = factor(historial),
    vivienda = factor(vivienda)
  )

glimpse(datos)
Rows: 1,000
Columns: 8
$ edad               <dbl> 35, 39, 61, 43, 44, 63, 48, 27, 34, 37, 57, 46, 47,…
$ ingresos           <dbl> 20992, 20579, 32595, 30962, 10434, 52484, 36768, 97…
$ deuda              <dbl> 4305, 7002, 4222, 13260, 6722, 4024, 1855, 3951, 22…
$ antiguedad_laboral <dbl> 6, 5, 0, 4, 20, 7, 12, 6, 10, 7, 14, 4, 0, 11, 6, 1…
$ num_productos      <int> 2, 5, 1, 2, 3, 4, 1, 4, 3, 5, 4, 5, 3, 3, 2, 4, 2, …
$ historial          <fct> Bueno, Bueno, Regular, Bueno, Bueno, Bueno, Bueno, …
$ vivienda           <fct> Propia, Alquiler, Familia, Alquiler, Alquiler, Alqu…
$ Class              <fct> Bad, Bad, Good, Bad, Bad, Good, Good, Good, Bad, Ba…
Mostrar código
np.random.seed(123)

n = 1000
edad = np.round(np.random.normal(42, 12, n)).astype(int)
ingresos = np.round(np.random.lognormal(10.4, 0.45, n)).astype(int)
deuda = np.round(np.random.lognormal(8.7, 0.65, n)).astype(int)
antiguedad_laboral = np.maximum(0, np.round(np.random.normal(7, 5, n))).astype(int)
num_productos = np.random.choice([1, 2, 3, 4, 5], size=n, replace=True)
historial = np.random.choice(["Bueno", "Regular", "Malo"], size=n, p=[0.55, 0.30, 0.15])
vivienda = np.random.choice(["Propia", "Alquiler", "Familia"], size=n, p=[0.45, 0.40, 0.15])

score_riesgo = (
    -0.000035 * ingresos +
    0.00018 * deuda -
    0.08 * antiguedad_laboral +
    0.23 * num_productos +
    np.where(historial == "Malo", 1.4, np.where(historial == "Regular", 0.55, -0.45)) +
    np.where(vivienda == "Alquiler", 0.25, 0) +
    np.random.normal(0, 0.9, n)
)

prob_bad = 1 / (1 + np.exp(-score_riesgo))
Class = np.where(np.random.rand(n) < prob_bad, "Bad", "Good")

datos_py = pd.DataFrame({
    "edad": edad,
    "ingresos": ingresos,
    "deuda": deuda,
    "antiguedad_laboral": antiguedad_laboral,
    "num_productos": num_productos,
    "historial": historial,
    "vivienda": vivienda,
    "Class": Class
})

objetivo_py = "Class"

datos_py.head()
   edad  ingresos  deuda  ...  historial  vivienda Class
0    29     23459   1895  ...      Bueno  Alquiler  Good
1    54     42422   2749  ...       Malo    Propia  Good
2    45     45395  12241  ...    Regular  Alquiler  Good
3    24     20958  10506  ...      Bueno   Familia   Bad
4    35     40689   2234  ...    Regular    Propia   Bad

[5 rows x 8 columns]
Mostrar código
datos_py.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1000 entries, 0 to 999
Data columns (total 8 columns):
 #   Column              Non-Null Count  Dtype 
---  ------              --------------  ----- 
 0   edad                1000 non-null   int64 
 1   ingresos            1000 non-null   int64 
 2   deuda               1000 non-null   int64 
 3   antiguedad_laboral  1000 non-null   int64 
 4   num_productos       1000 non-null   int64 
 5   historial           1000 non-null   object
 6   vivienda            1000 non-null   object
 7   Class               1000 non-null   object
dtypes: int64(5), object(3)
memory usage: 62.6+ KB

Interpretación.
La salida de R (glimpse) y la salida de Python (head e info) permiten comprobar que la base tiene variables numéricas, variables categóricas y una variable objetivo. Esta mezcla es realista: en problemas de scoring o auditoría bancaria suele haber importes, edades, ratios y categorías como historial, producto, segmento o tipo de vivienda.

4 4. Exploración inicial de la variable objetivo

Antes de entrenar un modelo hay que comprobar si la variable objetivo está equilibrada.

Mostrar código
cat("Filas:", nrow(datos), "\n")
Filas: 1000 
Mostrar código
cat("Columnas:", ncol(datos), "\n\n")
Columnas: 8 
Mostrar código
print(table(datos[[objetivo]]))

 Bad Good 
 565  435 
Mostrar código
print(round(prop.table(table(datos[[objetivo]])), 3))

  Bad  Good 
0.565 0.435 
Mostrar código
datos %>%
  count(Class) %>%
  ggplot(aes(x = Class, y = n)) +
  geom_col() +
  labs(
    title = "Distribución de la variable objetivo",
    x = "Clase",
    y = "Número de observaciones"
  ) +
  theme_minimal()

Mostrar código
print("Filas:", datos_py.shape[0])
Filas: 1000
Mostrar código
print("Columnas:", datos_py.shape[1])
Columnas: 8
Mostrar código
print("\nFrecuencias absolutas:")

Frecuencias absolutas:
Mostrar código
print(datos_py[objetivo_py].value_counts())
Class
Bad     558
Good    442
Name: count, dtype: int64
Mostrar código
print("\nFrecuencias relativas:")

Frecuencias relativas:
Mostrar código
print((datos_py[objetivo_py].value_counts(normalize=True)).round(3))
Class
Bad     0.558
Good    0.442
Name: proportion, dtype: float64
Mostrar código
conteo = datos_py[objetivo_py].value_counts().sort_index()
plt.figure(figsize=(6, 4))
plt.bar(conteo.index, conteo.values)
plt.title("Distribución de la variable objetivo")
plt.xlabel("Clase")
plt.ylabel("Número de observaciones")
plt.show()

Interpretación.
La tabla de frecuencias indica cuántos casos hay de cada clase. Si una clase domina demasiado, la accuracy puede ser engañosa. Por ejemplo, si el 90% fueran Good, un modelo que siempre predice Good tendría 90% de accuracy pero sería inútil para detectar clientes Bad. Por eso más adelante miramos también sensibilidad, especificidad, F1 y AUC.

5 5. Separación entre entrenamiento y test

Separamos los datos en:

  • train: usado para entrenar y validar internamente.
  • test: usado al final para evaluar con datos no vistos.
Mostrar código
set.seed(123)

idx_train <- createDataPartition(datos[[objetivo]], p = 0.8, list = FALSE)
train <- datos[idx_train, ]
test <- datos[-idx_train, ]

train[[objetivo]] <- factor(train[[objetivo]], levels = c("Bad", "Good"))
test[[objetivo]] <- factor(test[[objetivo]], levels = c("Bad", "Good"))

clase_positiva <- "Bad"
clase_negativa <- "Good"

cat("Train:", nrow(train), "filas\n")
Train: 800 filas
Mostrar código
cat("Test:", nrow(test), "filas\n\n")
Test: 200 filas
Mostrar código
print(round(prop.table(table(train[[objetivo]])), 3))

  Bad  Good 
0.565 0.435 
Mostrar código
print(round(prop.table(table(test[[objetivo]])), 3))

  Bad  Good 
0.565 0.435 
Mostrar código
X_py = datos_py.drop(columns=[objetivo_py])
y_py = datos_py[objetivo_py]

X_py = pd.get_dummies(X_py, drop_first=False)

X_train_py, X_test_py, y_train_py, y_test_py = train_test_split(
    X_py,
    y_py,
    test_size=0.2,
    random_state=123,
    stratify=y_py
)

print("Train:", X_train_py.shape)
Train: (800, 11)
Mostrar código
print("Test:", X_test_py.shape)
Test: (200, 11)
Mostrar código
print("\nDistribución train:")

Distribución train:
Mostrar código
print(y_train_py.value_counts(normalize=True).round(3))
Class
Bad     0.558
Good    0.442
Name: proportion, dtype: float64
Mostrar código
print("\nDistribución test:")

Distribución test:
Mostrar código
print(y_test_py.value_counts(normalize=True).round(3))
Class
Bad     0.56
Good    0.44
Name: proportion, dtype: float64

Interpretación.
La distribución de clases en train y test debería ser parecida. Esto es importante porque el modelo se entrena en una muestra y se evalúa en otra. Si el test tuviera una distribución muy distinta, la comparación sería menos fiable.

6 6. Control de entrenamiento y preprocesado

SVM es sensible a la escala. Una variable como ingresos puede tener valores de decenas de miles, mientras que num_productos tiene valores de 1 a 5. Por eso es obligatorio escalar.

Mostrar código
formula_modelo <- as.formula(paste(objetivo, "~ ."))

ctrl <- trainControl(
  method = "cv",
  number = 5,
  classProbs = TRUE,
  summaryFunction = twoClassSummary,
  savePredictions = "final",
  verboseIter = FALSE
)

formula_modelo
Class ~ .
Mostrar código
cv_py = StratifiedKFold(n_splits=5, shuffle=True, random_state=123)

print("Validación cruzada estratificada con 5 folds")
Validación cruzada estratificada con 5 folds
Mostrar código
print("Clase positiva para métricas:", "Bad")
Clase positiva para métricas: Bad

Interpretación.
En R, trainControl() configura una validación cruzada de 5 particiones y pide que se optimice con ROC. En Python, StratifiedKFold hace lo mismo: divide manteniendo la proporción de clases. El escalado se incorpora dentro de cada pipeline/modelo para evitar fuga de información.

7 7. SVM lineal

El SVM lineal busca una frontera recta, o hiperplano, que separe las clases. Es el modelo más simple y suele ser una buena referencia inicial.

Mostrar código
set.seed(123)

grid_svm_lineal <- expand.grid(C = c(0.01, 0.1, 1, 10, 100))

modelo_svm_lineal <- train(
  formula_modelo,
  data = train,
  method = "svmLinear",
  metric = "ROC",
  trControl = ctrl,
  preProcess = c("medianImpute", "center", "scale"),
  tuneGrid = grid_svm_lineal
)

modelo_svm_lineal
Support Vector Machines with Linear Kernel 

800 samples
  7 predictor
  2 classes: 'Bad', 'Good' 

Pre-processing: median imputation (9), centered (9), scaled (9) 
Resampling: Cross-Validated (5 fold) 
Summary of sample sizes: 640, 640, 640, 641, 639 
Resampling results across tuning parameters:

  C      ROC        Sens       Spec     
  1e-02  0.7658484  0.7232967  0.6552795
  1e-01  0.7683499  0.7630281  0.6035611
  1e+00  0.7675154  0.7631013  0.6036025
  1e+01  0.7675186  0.7608791  0.6150311
  1e+02  0.7674555  0.7609035  0.6036025

ROC was used to select the optimal model using the largest value.
The final value used for the model was C = 0.1.
Mostrar código
if (nrow(modelo_svm_lineal$results) > 1) {
  plot(modelo_svm_lineal)
} else {
  print(modelo_svm_lineal$results)
}

Mostrar código
best_lineal_r <- modelo_svm_lineal$bestTune$C
best_roc_lineal_r <- max(modelo_svm_lineal$results$ROC, na.rm = TRUE)
cat("Mejor C del SVM lineal:", best_lineal_r, "\n")
Mejor C del SVM lineal: 0.1 
Mostrar código
cat("Mejor ROC medio en validación cruzada:", round(best_roc_lineal_r, 4), "\n")
Mejor ROC medio en validación cruzada: 0.7683 
Mostrar código
pipe_lineal_py = Pipeline([
    ("scaler", StandardScaler()),
    ("svm", SVC(kernel="linear", probability=True, random_state=123))
])

grid_lineal_py = {
    "svm__C": [0.01, 0.1, 1, 10, 100]
}

modelo_svm_lineal_py = GridSearchCV(
    estimator=pipe_lineal_py,
    param_grid=grid_lineal_py,
    scoring="roc_auc",
    cv=cv_py,
    n_jobs=-1
)

modelo_svm_lineal_py.fit(X_train_py, y_train_py)
GridSearchCV(cv=StratifiedKFold(n_splits=5, random_state=123, shuffle=True),
             estimator=Pipeline(steps=[('scaler', StandardScaler()),
                                       ('svm',
                                        SVC(kernel='linear', probability=True,
                                            random_state=123))]),
             n_jobs=-1, param_grid={'svm__C': [0.01, 0.1, 1, 10, 100]},
             scoring='roc_auc')
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Mostrar código
print("Mejores hiperparámetros:", modelo_svm_lineal_py.best_params_)
Mejores hiperparámetros: {'svm__C': 100}
Mostrar código
print("Mejor ROC medio CV:", round(modelo_svm_lineal_py.best_score_, 4))
Mejor ROC medio CV: 0.7456
Mostrar código
res_lineal_py = pd.DataFrame(modelo_svm_lineal_py.cv_results_)
res_lineal_py[["param_svm__C", "mean_test_score", "std_test_score"]].sort_values("mean_test_score", ascending=False)
   param_svm__C  mean_test_score  std_test_score
4        100.00         0.745642        0.031322
3         10.00         0.745483        0.031346
2          1.00         0.745420        0.031435
1          0.10         0.744533        0.031586
0          0.01         0.743775        0.030072
Mostrar código
plt.figure(figsize=(6, 4))
plt.plot(res_lineal_py["param_svm__C"].astype(float), res_lineal_py["mean_test_score"], marker="o")
plt.xscale("log")
plt.title("SVM lineal - ROC medio según C")
plt.xlabel("C")
plt.ylabel("ROC AUC medio CV")
plt.show()

Interpretación.
El parámetro C controla la penalización por errores. Un C bajo permite más margen y acepta más errores; un C alto intenta clasificar mejor los datos de entrenamiento, pero puede sobreajustar. Si la curva mejora al subir C, el modelo necesitaba más flexibilidad. Si empeora, probablemente está empezando a memorizar ruido.

8 8. SVM radial

El SVM radial usa un kernel RBF. Permite fronteras curvas y suele funcionar mejor cuando la separación entre clases no es lineal.

Mostrar código
set.seed(123)

grid_svm_radial <- expand.grid(
  sigma = c(0.001, 0.005, 0.01, 0.05, 0.1),
  C = c(0.25, 0.5, 1, 2, 4)
)

modelo_svm_radial <- train(
  formula_modelo,
  data = train,
  method = "svmRadial",
  metric = "ROC",
  trControl = ctrl,
  preProcess = c("medianImpute", "center", "scale"),
  tuneGrid = grid_svm_radial
)

modelo_svm_radial
Support Vector Machines with Radial Basis Function Kernel 

800 samples
  7 predictor
  2 classes: 'Bad', 'Good' 

Pre-processing: median imputation (9), centered (9), scaled (9) 
Resampling: Cross-Validated (5 fold) 
Summary of sample sizes: 640, 640, 640, 641, 639 
Resampling results across tuning parameters:

  sigma  C     ROC        Sens       Spec     
  0.001  0.25  0.7608075  0.6038828  0.7817391
  0.001  0.50  0.7608390  0.6016117  0.7760248
  0.001  1.00  0.7605514  0.5949206  0.7843064
  0.001  2.00  0.7610766  0.6304029  0.7385921
  0.001  4.00  0.7656724  0.6900855  0.6755280
  0.005  0.25  0.7605891  0.5838339  0.7874534
  0.005  0.50  0.7626954  0.6503053  0.7240580
  0.005  1.00  0.7668680  0.7343834  0.6554037
  0.005  2.00  0.7667072  0.7541880  0.6236439
  0.005  4.00  0.7666771  0.7741636  0.6006211
  0.010  0.25  0.7623419  0.6237851  0.7356108
  0.010  0.50  0.7659606  0.7166545  0.6553623
  0.010  1.00  0.7660881  0.7586325  0.6265839
  0.010  2.00  0.7675593  0.7630281  0.6151139
  0.010  4.00  0.7673236  0.7542125  0.6180124
  0.050  0.25  0.7604186  0.7454212  0.6208696
  0.050  0.50  0.7614140  0.7454701  0.6152795
  0.050  1.00  0.7570444  0.7387546  0.6354451
  0.050  2.00  0.7508242  0.7564347  0.6095238
  0.050  4.00  0.7418177  0.7343101  0.5982195
  0.100  0.25  0.7495486  0.7498901  0.5921325
  0.100  0.50  0.7469240  0.7587790  0.5951967
  0.100  1.00  0.7394010  0.7476190  0.5894824
  0.100  2.00  0.7283145  0.7454212  0.5809938
  0.100  4.00  0.7101801  0.7408791  0.5607453

ROC was used to select the optimal model using the largest value.
The final values used for the model were sigma = 0.01 and C = 2.
Mostrar código
if (nrow(modelo_svm_radial$results) > 1) {
  plot(modelo_svm_radial)
} else {
  print(modelo_svm_radial$results)
}

Mostrar código
cat("Mejor sigma:", modelo_svm_radial$bestTune$sigma, "\n")
Mejor sigma: 0.01 
Mostrar código
cat("Mejor C:", modelo_svm_radial$bestTune$C, "\n")
Mejor C: 2 
Mostrar código
cat("Mejor ROC medio CV:", round(max(modelo_svm_radial$results$ROC, na.rm = TRUE), 4), "\n")
Mejor ROC medio CV: 0.7676 
Mostrar código
pipe_radial_py = Pipeline([
    ("scaler", StandardScaler()),
    ("svm", SVC(kernel="rbf", probability=True, random_state=123))
])

grid_radial_py = {
    "svm__C": [0.25, 0.5, 1, 2, 4],
    "svm__gamma": [0.001, 0.005, 0.01, 0.05, 0.1]
}

modelo_svm_radial_py = GridSearchCV(
    estimator=pipe_radial_py,
    param_grid=grid_radial_py,
    scoring="roc_auc",
    cv=cv_py,
    n_jobs=-1
)

modelo_svm_radial_py.fit(X_train_py, y_train_py)
GridSearchCV(cv=StratifiedKFold(n_splits=5, random_state=123, shuffle=True),
             estimator=Pipeline(steps=[('scaler', StandardScaler()),
                                       ('svm',
                                        SVC(probability=True,
                                            random_state=123))]),
             n_jobs=-1,
             param_grid={'svm__C': [0.25, 0.5, 1, 2, 4],
                         'svm__gamma': [0.001, 0.005, 0.01, 0.05, 0.1]},
             scoring='roc_auc')
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Mostrar código
print("Mejores hiperparámetros:", modelo_svm_radial_py.best_params_)
Mejores hiperparámetros: {'svm__C': 2, 'svm__gamma': 0.005}
Mostrar código
print("Mejor ROC medio CV:", round(modelo_svm_radial_py.best_score_, 4))
Mejor ROC medio CV: 0.7429
Mostrar código
res_radial_py = pd.DataFrame(modelo_svm_radial_py.cv_results_)
res_radial_py[["param_svm__C", "param_svm__gamma", "mean_test_score", "std_test_score"]].sort_values("mean_test_score", ascending=False).head(10)
    param_svm__C  param_svm__gamma  mean_test_score  std_test_score
16          2.00             0.005         0.742920        0.030892
11          1.00             0.005         0.741497        0.028992
20          4.00             0.001         0.741118        0.029756
21          4.00             0.005         0.740956        0.028924
12          1.00             0.010         0.740704        0.030508
7           0.50             0.010         0.739819        0.029844
17          2.00             0.010         0.739024        0.028760
6           0.50             0.005         0.737442        0.032919
2           0.25             0.010         0.736872        0.032549
15          2.00             0.001         0.736555        0.032259

Interpretación.
En el SVM radial aparecen dos hiperparámetros importantes: C y sigma/gamma. gamma controla cuánto influye cada punto sobre su entorno. Un valor muy pequeño genera fronteras suaves; un valor muy alto puede crear fronteras demasiado retorcidas. Si el radial supera claramente al lineal, significa que probablemente hay relaciones no lineales entre las variables y la clase.

9 9. SVM polinomial

El SVM polinomial permite fronteras curvas mediante combinaciones polinómicas de las variables.

Mostrar código
set.seed(123)

grid_svm_poly <- expand.grid(
  degree = c(2, 3),
  scale = c(0.001, 0.01, 0.1),
  C = c(0.25, 1, 4)
)

modelo_svm_poly <- train(
  formula_modelo,
  data = train,
  method = "svmPoly",
  metric = "ROC",
  trControl = ctrl,
  preProcess = c("medianImpute", "center", "scale"),
  tuneGrid = grid_svm_poly
)

modelo_svm_poly
Support Vector Machines with Polynomial Kernel 

800 samples
  7 predictor
  2 classes: 'Bad', 'Good' 

Pre-processing: median imputation (9), centered (9), scaled (9) 
Resampling: Cross-Validated (5 fold) 
Summary of sample sizes: 640, 640, 640, 641, 639 
Resampling results across tuning parameters:

  degree  scale  C     ROC        Sens       Spec     
  2       0.001  0.25  0.7606464  0.6169963  0.7730435
  2       0.001  1.00  0.7606446  0.6015629  0.7671636
  2       0.001  4.00  0.7657703  0.6900611  0.6899793
  2       0.010  0.25  0.7633735  0.6679853  0.7096894
  2       0.010  1.00  0.7674376  0.7608547  0.6121325
  2       0.010  4.00  0.7669683  0.7497436  0.6180538
  2       0.100  0.25  0.7586451  0.7497680  0.6125466
  2       0.100  1.00  0.7462764  0.7410012  0.6038095
  2       0.100  4.00  0.7409344  0.7609280  0.5722153
  3       0.001  0.25  0.7605488  0.6059829  0.7585921
  3       0.001  1.00  0.7605815  0.5949206  0.7959834
  3       0.001  4.00  0.7659731  0.7499634  0.6351139
  3       0.010  0.25  0.7654789  0.6834432  0.6812836
  3       0.010  1.00  0.7665741  0.7608303  0.6035197
  3       0.010  4.00  0.7646371  0.7586081  0.6238095
  3       0.100  0.25  0.7410596  0.7255189  0.6154451
  3       0.100  1.00  0.7182294  0.7454212  0.5607039
  3       0.100  4.00  0.6837823  0.7476679  0.4571843

ROC was used to select the optimal model using the largest value.
The final values used for the model were degree = 2, scale = 0.01 and C = 1.
Mostrar código
if (nrow(modelo_svm_poly$results) > 1) {
  plot(modelo_svm_poly)
} else {
  print(modelo_svm_poly$results)
}

Mostrar código
print(modelo_svm_poly$bestTune)
  degree scale C
5      2  0.01 1
Mostrar código
cat("Mejor ROC medio CV:", round(max(modelo_svm_poly$results$ROC, na.rm = TRUE), 4), "\n")
Mejor ROC medio CV: 0.7674 
Mostrar código
pipe_poly_py = Pipeline([
    ("scaler", StandardScaler()),
    ("svm", SVC(kernel="poly", probability=True, random_state=123))
])

grid_poly_py = {
    "svm__C": [0.25, 1, 4],
    "svm__gamma": [0.001, 0.01, 0.1],
    "svm__degree": [2, 3],
    "svm__coef0": [0, 1]
}

modelo_svm_poly_py = GridSearchCV(
    estimator=pipe_poly_py,
    param_grid=grid_poly_py,
    scoring="roc_auc",
    cv=cv_py,
    n_jobs=-1
)

modelo_svm_poly_py.fit(X_train_py, y_train_py)
GridSearchCV(cv=StratifiedKFold(n_splits=5, random_state=123, shuffle=True),
             estimator=Pipeline(steps=[('scaler', StandardScaler()),
                                       ('svm',
                                        SVC(kernel='poly', probability=True,
                                            random_state=123))]),
             n_jobs=-1,
             param_grid={'svm__C': [0.25, 1, 4], 'svm__coef0': [0, 1],
                         'svm__degree': [2, 3],
                         'svm__gamma': [0.001, 0.01, 0.1]},
             scoring='roc_auc')
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Mostrar código
print("Mejores hiperparámetros:", modelo_svm_poly_py.best_params_)
Mejores hiperparámetros: {'svm__C': 4, 'svm__coef0': 1, 'svm__degree': 3, 'svm__gamma': 0.001}
Mostrar código
print("Mejor ROC medio CV:", round(modelo_svm_poly_py.best_score_, 4))
Mejor ROC medio CV: 0.7435
Mostrar código
res_poly_py = pd.DataFrame(modelo_svm_poly_py.cv_results_)
res_poly_py[["param_svm__C", "param_svm__gamma", "param_svm__degree", "param_svm__coef0", "mean_test_score", "std_test_score"]].sort_values("mean_test_score", ascending=False).head(10)
    param_svm__C  param_svm__gamma  ...  mean_test_score  std_test_score
33          4.00             0.001  ...         0.743459        0.030373
19          1.00             0.010  ...         0.742635        0.030313
30          4.00             0.001  ...         0.740896        0.029681
10          0.25             0.010  ...         0.738711        0.030708
31          4.00             0.010  ...         0.738265        0.029548
7           0.25             0.010  ...         0.738012        0.031475
22          1.00             0.010  ...         0.738011        0.028645
21          1.00             0.001  ...         0.735226        0.033255
6           0.25             0.001  ...         0.734940        0.033008
18          1.00             0.001  ...         0.734814        0.033108

[10 rows x 6 columns]

Interpretación.
El kernel polinomial puede capturar patrones curvos, pero suele ser más sensible a la elección de hiperparámetros. El grado (degree) indica la complejidad de las interacciones. Un grado 2 crea relaciones cuadráticas; un grado 3 permite relaciones cúbicas. Si el polinomial no mejora al radial, normalmente se prefiere el radial porque es más flexible y suele ser más estable.

10 10. Comparación de modelos

Comparamos los tres modelos usando el ROC medio de validación cruzada.

Mostrar código
roc_lineal <- max(modelo_svm_lineal$results$ROC, na.rm = TRUE)
roc_radial <- max(modelo_svm_radial$results$ROC, na.rm = TRUE)
roc_poly <- max(modelo_svm_poly$results$ROC, na.rm = TRUE)

comparacion_r <- tibble(
  modelo = c("SVM lineal", "SVM radial", "SVM polinomial"),
  ROC_CV = c(roc_lineal, roc_radial, roc_poly)
) %>% arrange(desc(ROC_CV))

comparacion_r
Mostrar código
ggplot(comparacion_r, aes(x = reorder(modelo, ROC_CV), y = ROC_CV)) +
  geom_col() +
  coord_flip() +
  labs(
    title = "Comparación de modelos SVM",
    x = "Modelo",
    y = "ROC AUC medio en CV"
  ) +
  theme_minimal()

Mostrar código
mejor_nombre <- comparacion_r$modelo[1]

if (mejor_nombre == "SVM radial") {
  mejor_modelo <- modelo_svm_radial
} else if (mejor_nombre == "SVM polinomial") {
  mejor_modelo <- modelo_svm_poly
} else {
  mejor_modelo <- modelo_svm_lineal
}

cat("Mejor modelo en R:", mejor_nombre, "\n")
Mejor modelo en R: SVM lineal 
Mostrar código
comparacion_py = pd.DataFrame({
    "modelo": ["SVM lineal", "SVM radial", "SVM polinomial"],
    "ROC_CV": [
        modelo_svm_lineal_py.best_score_,
        modelo_svm_radial_py.best_score_,
        modelo_svm_poly_py.best_score_
    ]
}).sort_values("ROC_CV", ascending=False)

comparacion_py
           modelo    ROC_CV
0      SVM lineal  0.745642
2  SVM polinomial  0.743459
1      SVM radial  0.742920
Mostrar código
plt.figure(figsize=(7, 4))
plt.barh(comparacion_py["modelo"], comparacion_py["ROC_CV"])
plt.gca().invert_yaxis()
plt.title("Comparación de modelos SVM")
plt.xlabel("ROC AUC medio en CV")
plt.ylabel("Modelo")
plt.show()

Mostrar código
mejor_nombre_py = comparacion_py.iloc[0]["modelo"]

if mejor_nombre_py == "SVM radial":
    mejor_modelo_py = modelo_svm_radial_py
elif mejor_nombre_py == "SVM polinomial":
    mejor_modelo_py = modelo_svm_poly_py
else:
    mejor_modelo_py = modelo_svm_lineal_py

print("Mejor modelo en Python:", mejor_nombre_py)
Mejor modelo en Python: SVM lineal

Interpretación.
El modelo con mayor ROC medio en validación cruzada es el candidato principal. El ROC AUC mide la capacidad del modelo para ordenar correctamente casos positivos y negativos. Un valor cercano a 0.5 equivale a azar; cuanto más se acerque a 1, mejor separa las clases. La decisión final no debería basarse solo en ROC: también conviene mirar recall de la clase Bad, porque en riesgo de crédito normalmente interesa detectar los malos perfiles.

11 11. Evaluación en test

Ahora se evalúa el mejor modelo con datos que no se han usado durante el entrenamiento.

Mostrar código
pred_clase <- predict(mejor_modelo, newdata = test)
pred_prob <- predict(mejor_modelo, newdata = test, type = "prob")

head(pred_prob)
Mostrar código
cm_r <- confusionMatrix(
  data = pred_clase,
  reference = test[[objetivo]],
  positive = clase_positiva
)
cm_r
Confusion Matrix and Statistics

          Reference
Prediction Bad Good
      Bad   89   36
      Good  24   51
                                          
               Accuracy : 0.7             
                 95% CI : (0.6314, 0.7626)
    No Information Rate : 0.565           
    P-Value [Acc > NIR] : 6.019e-05       
                                          
                  Kappa : 0.3798          
                                          
 Mcnemar's Test P-Value : 0.1556          
                                          
            Sensitivity : 0.7876          
            Specificity : 0.5862          
         Pos Pred Value : 0.7120          
         Neg Pred Value : 0.6800          
             Prevalence : 0.5650          
         Detection Rate : 0.4450          
   Detection Prevalence : 0.6250          
      Balanced Accuracy : 0.6869          
                                          
       'Positive' Class : Bad             
                                          
Mostrar código
metricas_df <- tibble(
  truth = test[[objetivo]],
  estimate = pred_clase,
  prob_bad = pred_prob[[clase_positiva]]
)

metricas_r <- bind_rows(
  accuracy(metricas_df, truth = truth, estimate = estimate),
  precision(metricas_df, truth = truth, estimate = estimate, event_level = "first"),
  recall(metricas_df, truth = truth, estimate = estimate, event_level = "first"),
  f_meas(metricas_df, truth = truth, estimate = estimate, event_level = "first"),
  roc_auc(metricas_df, truth = truth, prob_bad, event_level = "first")
)

metricas_r
Mostrar código
y_pred_py = mejor_modelo_py.predict(X_test_py)
y_prob_py = mejor_modelo_py.predict_proba(X_test_py)[:, list(mejor_modelo_py.classes_).index("Bad")]

print("Matriz de confusión:")
Matriz de confusión:
Mostrar código
print(confusion_matrix(y_test_py, y_pred_py, labels=["Bad", "Good"]))
[[90 22]
 [34 54]]
Mostrar código
print("\nReporte de clasificación:")

Reporte de clasificación:
Mostrar código
print(classification_report(y_test_py, y_pred_py, labels=["Bad", "Good"]))
              precision    recall  f1-score   support

         Bad       0.73      0.80      0.76       112
        Good       0.71      0.61      0.66        88

    accuracy                           0.72       200
   macro avg       0.72      0.71      0.71       200
weighted avg       0.72      0.72      0.72       200
Mostrar código
metricas_py = pd.DataFrame({
    "metrica": ["accuracy", "precision_Bad", "recall_Bad", "f1_Bad", "roc_auc"],
    "valor": [
        accuracy_score(y_test_py, y_pred_py),
        precision_score(y_test_py, y_pred_py, pos_label="Bad"),
        recall_score(y_test_py, y_pred_py, pos_label="Bad"),
        f1_score(y_test_py, y_pred_py, pos_label="Bad"),
        roc_auc_score((y_test_py == "Bad").astype(int), y_prob_py)
    ]
})

metricas_py
         metrica     valor
0       accuracy  0.720000
1  precision_Bad  0.725806
2     recall_Bad  0.803571
3         f1_Bad  0.762712
4        roc_auc  0.785106

Interpretación.
La matriz de confusión muestra cuatro cantidades: aciertos de Bad, errores donde un Bad se clasifica como Good, aciertos de Good y errores donde un Good se clasifica como Bad. En un problema de riesgo, el error más peligroso suele ser clasificar como Good a alguien que realmente es Bad. Por eso el recall_Bad es especialmente importante: mide qué proporción de los clientes problemáticos detecta el modelo.

12 12. Curva ROC

La curva ROC muestra el equilibrio entre sensibilidad y falsos positivos para distintos umbrales de decisión.

Mostrar código
roc_obj <- roc(
  response = test[[objetivo]],
  predictor = pred_prob[[clase_positiva]],
  levels = c("Good", "Bad")
)

plot(roc_obj, main = paste("Curva ROC -", mejor_nombre))

Mostrar código
auc(roc_obj)
Area under the curve: 0.7427
Mostrar código
RocCurveDisplay.from_predictions(
    (y_test_py == "Bad").astype(int),
    y_prob_py
)
<sklearn.metrics._plot.roc_curve.RocCurveDisplay object at 0x0000022103AACBD0>
Mostrar código
plt.title(f"Curva ROC - {mejor_nombre_py}")
plt.show()

Mostrar código
print("AUC:", round(roc_auc_score((y_test_py == "Bad").astype(int), y_prob_py), 4))
AUC: 0.7851

Interpretación.
La curva ROC permite ver si el modelo separa bien las dos clases a distintos umbrales. Si la curva está cerca de la diagonal, el modelo apenas distingue entre Bad y Good. Si la curva se acerca a la esquina superior izquierda, el modelo separa bien. El AUC resume esa curva en un único número.

13 13. Visualización de fronteras: lineal, radial y polinomial

Los datos reales tienen muchas variables, por lo que no se puede dibujar directamente la frontera del SVM. Para entender visualmente los kernels, generamos un problema 2D sintético.

Mostrar código
set.seed(123)

n_vis <- 400
x1 <- rnorm(n_vis)
x2 <- rnorm(n_vis)
ruido <- rnorm(n_vis, sd = 0.30)

clase <- ifelse(x1^2 + x2^2 + ruido > 1.25, "Exterior", "Interior")

datos_vis <- tibble(
  x1 = x1,
  x2 = x2,
  clase = factor(clase, levels = c("Interior", "Exterior"))
)

ggplot(datos_vis, aes(x = x1, y = x2, color = clase)) +
  geom_point(alpha = 0.8) +
  labs(
    title = "Datos sintéticos 2D",
    x = "x1",
    y = "x2"
  ) +
  theme_minimal()

Mostrar código
modelo_vis_lineal <- e1071::svm(clase ~ x1 + x2, data = datos_vis, kernel = "linear", cost = 1, scale = TRUE)
modelo_vis_radial <- e1071::svm(clase ~ x1 + x2, data = datos_vis, kernel = "radial", cost = 1, gamma = 1, scale = TRUE)
modelo_vis_poly <- e1071::svm(clase ~ x1 + x2, data = datos_vis, kernel = "polynomial", cost = 1, gamma = 1, degree = 3, coef0 = 1, scale = TRUE)
Mostrar código
graficar_frontera_svm <- function(modelo, datos, titulo) {
  x1_seq <- seq(min(datos$x1) - 0.5, max(datos$x1) + 0.5, length.out = 250)
  x2_seq <- seq(min(datos$x2) - 0.5, max(datos$x2) + 0.5, length.out = 250)

  grid <- expand.grid(x1 = x1_seq, x2 = x2_seq)
  grid$pred <- predict(modelo, newdata = grid)

  ggplot() +
    geom_tile(data = grid, aes(x = x1, y = x2, fill = pred), alpha = 0.25) +
    geom_contour(
      data = grid %>% mutate(pred_num = as.numeric(pred)),
      aes(x = x1, y = x2, z = pred_num),
      breaks = 1.5,
      linewidth = 0.8
    ) +
    geom_point(data = datos, aes(x = x1, y = x2, color = clase), alpha = 0.85) +
    labs(
      title = titulo,
      x = "x1",
      y = "x2",
      fill = "Región predicha",
      color = "Clase real"
    ) +
    theme_minimal()
}
Mostrar código
graficar_frontera_svm(modelo_vis_lineal, datos_vis, "Frontera de decisión - SVM lineal")

Mostrar código
graficar_frontera_svm(modelo_vis_radial, datos_vis, "Frontera de decisión - SVM radial")

Mostrar código
graficar_frontera_svm(modelo_vis_poly, datos_vis, "Frontera de decisión - SVM polinomial")

Mostrar código
np.random.seed(123)

X_vis_py, y_vis_num_py = make_circles(n_samples=400, noise=0.18, factor=0.45, random_state=123)
y_vis_py = np.where(y_vis_num_py == 1, "Interior", "Exterior")

plt.figure(figsize=(6, 5))
plt.scatter(X_vis_py[:, 0], X_vis_py[:, 1], c=(y_vis_py == "Interior").astype(int), alpha=0.8)
plt.title("Datos sintéticos 2D")
plt.xlabel("x1")
plt.ylabel("x2")
plt.show()

Mostrar código
modelos_vis_py = {
    "SVM lineal": Pipeline([
        ("scaler", StandardScaler()),
        ("svm", SVC(kernel="linear", C=1))
    ]),
    "SVM radial": Pipeline([
        ("scaler", StandardScaler()),
        ("svm", SVC(kernel="rbf", C=1, gamma=1))
    ]),
    "SVM polinomial": Pipeline([
        ("scaler", StandardScaler()),
        ("svm", SVC(kernel="poly", C=1, gamma=1, degree=3, coef0=1))
    ])
}

for nombre, modelo in modelos_vis_py.items():
    modelo.fit(X_vis_py, y_vis_py)
Pipeline(steps=[('scaler', StandardScaler()),
                ('svm', SVC(C=1, coef0=1, gamma=1, kernel='poly'))])
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Mostrar código
def graficar_frontera_python(modelo, X, y, titulo):
    x_min, x_max = X[:, 0].min() - 0.5, X[:, 0].max() + 0.5
    y_min, y_max = X[:, 1].min() - 0.5, X[:, 1].max() + 0.5

    xx, yy = np.meshgrid(
        np.linspace(x_min, x_max, 300),
        np.linspace(y_min, y_max, 300)
    )

    grid = np.c_[xx.ravel(), yy.ravel()]
    pred = modelo.predict(grid)
    pred_num = (pred == "Interior").astype(int).reshape(xx.shape)

    plt.figure(figsize=(6, 5))
    plt.contourf(xx, yy, pred_num, alpha=0.25)
    plt.contour(xx, yy, pred_num, levels=[0.5])
    plt.scatter(X[:, 0], X[:, 1], c=(y == "Interior").astype(int), alpha=0.85)
    plt.title(titulo)
    plt.xlabel("x1")
    plt.ylabel("x2")
    plt.show()
Mostrar código
graficar_frontera_python(modelos_vis_py["SVM lineal"], X_vis_py, y_vis_py, "Frontera de decisión - SVM lineal")

Mostrar código
graficar_frontera_python(modelos_vis_py["SVM radial"], X_vis_py, y_vis_py, "Frontera de decisión - SVM radial")

Mostrar código
graficar_frontera_python(modelos_vis_py["SVM polinomial"], X_vis_py, y_vis_py, "Frontera de decisión - SVM polinomial")

Interpretación.
El gráfico lineal intenta separar los puntos con una frontera recta. Cuando las clases tienen forma circular, el lineal no puede adaptarse bien. El radial sí puede crear una frontera curva alrededor de una región. El polinomial también puede curvar la frontera, pero normalmente de forma menos local que el radial. Esta visualización explica por qué los kernels son útiles: transforman implícitamente el espacio para separar patrones que no son lineales en las variables originales.

14 14. Predicción de un nuevo registro

Finalmente, usamos el modelo entrenado para predecir un único cliente nuevo.

Mostrar código
nuevo_registro <- test[1, , drop = FALSE]
clase_real <- nuevo_registro[[objetivo]]
nuevo_registro[[objetivo]] <- NULL

pred_nuevo_clase <- predict(mejor_modelo, newdata = nuevo_registro)
pred_nuevo_prob <- predict(mejor_modelo, newdata = nuevo_registro, type = "prob")

cat("Clase real:", as.character(clase_real), "\n")
Clase real: Bad 
Mostrar código
cat("Clase predicha:", as.character(pred_nuevo_clase), "\n")
Clase predicha: Good 
Mostrar código
print(pred_nuevo_prob)
        Bad      Good
1 0.4012229 0.5987771
Mostrar código
nuevo_registro_py = X_test_py.iloc[[0]]
clase_real_py = y_test_py.iloc[0]

pred_nuevo_clase_py = mejor_modelo_py.predict(nuevo_registro_py)[0]
pred_nuevo_prob_py = mejor_modelo_py.predict_proba(nuevo_registro_py)[0]

prob_df_py = pd.DataFrame({
    "clase": mejor_modelo_py.classes_,
    "probabilidad": pred_nuevo_prob_py
})

print("Clase real:", clase_real_py)
Clase real: Bad
Mostrar código
print("Clase predicha:", pred_nuevo_clase_py)
Clase predicha: Bad
Mostrar código
prob_df_py
  clase  probabilidad
0   Bad      0.639683
1  Good      0.360317

Interpretación.
La predicción individual devuelve dos cosas: la clase final y la probabilidad estimada para cada clase. Si la probabilidad de Bad es alta, el cliente sería considerado de riesgo. Si las probabilidades están muy cerca, el modelo está inseguro y podría ser razonable revisar manualmente el caso o cambiar el umbral de decisión.

15 15. Conclusión

Un SVM es un modelo potente para clasificación, especialmente cuando se aplica correctamente el escalado y se prueban distintos kernels.

Resumen práctico:

  • El SVM lineal es simple, rápido e interpretable como frontera recta.
  • El SVM radial suele ser el más flexible y efectivo cuando la separación no es lineal.
  • El SVM polinomial permite relaciones curvas, pero puede ser más delicado de ajustar.
  • La comparación debe hacerse con validación cruzada y después confirmarse en test.
  • En problemas de riesgo, no basta con mirar accuracy: hay que mirar especialmente recall, precision, F1 y ROC AUC para la clase problemática.

Aquesta web està creada por Dante Conti y Sergi Ramírez, (c) 2026