Mostrar código
path <- 'https://raw.githubusercontent.com/ramIA-lab/MLforEducation/refs/heads/main/material/trees_ensambleMethods/idealista18_BCN_conRenta.csv'
BCN <- read.csv2(path)Dante Conti, Sergi Ramirez, (c) IDEAI
En este documento se estudia el algoritmo Random Forest aplicado a un problema de clasificación supervisada. El objetivo será predecir el nivel de renta del entorno de una vivienda de Barcelona (RENTA) a partir de características del inmueble, del edificio y de su localización.
La base de datos procede de anuncios de vivienda de Idealista y se ha enriquecido con información de renta media por hogar/persona a nivel de sección censal. A partir de la renta media por hogar se construye una variable categórica de tres niveles:
Por tanto, el problema se puede formular como:
\[Y = f(X_1, X_2, \ldots, X_p) + \varepsilon\]
donde:
RENTA.Un árbol de decisión construye reglas del tipo:
Si
DISTANCE_TO_CITY_CENTER < 2.5yHASLIFT = Sí, entonces clasificar comoRENTA = Alta.
El problema de un único árbol es que suele tener alta varianza: pequeñas variaciones en los datos de entrenamiento pueden generar árboles bastante diferentes. Esto puede provocar sobreajuste, es decir, un modelo que memoriza demasiado bien el conjunto de entrenamiento pero generaliza peor en datos nuevos.
Random Forest reduce este problema entrenando muchos árboles distintos y combinando sus predicciones. En clasificación, cada árbol vota una clase y el bosque devuelve la clase más votada.
\[\hat{y}_{RF} = \operatorname{mode}\{\hat{y}_1, \hat{y}_2, \ldots, \hat{y}_B\}\]
donde:
Random Forest introduce aleatoriedad de dos formas:
mtry en R o max_features en Python.Esto hace que los árboles sean menos correlacionados entre sí. Cuanto menor sea la correlación entre árboles, más beneficiosa será la agregación.
Random Forest es uno de los algoritmos clásicos más utilizados porque:
También tiene limitaciones importantes:
El siguiente bloque reproduce la carga de datos usada en el ejemplo de árboles de decisión. La versión final de la base de datos se carga directamente desde GitHub.
El preprocesamiento tiene tres objetivos principales:
No/Si);RENTA a partir de la renta media por hogar.BCN <- BCN %>%
select(-X, -PRICE, -LONGITUDE, -LATITUDE, -geometry, -CONSTRUCTIONYEAR,
-ASSETID, -PERIOD, -CUSEC, -CSEC, -CMUN, -CPRO, -CCA, -CUDIS, -CLAU2,
-NPRO, -NCA, -CNUT0, -CNUT1, -CNUT2, -CNUT3, -NMUN, -Shape_Leng,
-Shape_Area, -geometry, -CUMUN, -CADASTRALQUALITYID) %>%
mutate(
across(
.cols = starts_with(c("HAS", "IS")),
.fns = ~ case_when(. == 0 ~ "No", . == 1 ~ "Si"),
.names = "{.col}"),
AMENITYID = case_when(
AMENITYID == 1 ~ "SinMuebleSinCocina",
AMENITYID == 2 ~ "CocinaSinMuebles",
AMENITYID == 3 ~ "CocinaMuebles"),
FLATLOCATIONID = case_when(
FLATLOCATIONID == 1 ~ "exterior",
FLATLOCATIONID == 2 ~ "interior",
.default = "noInfo"),
BUILTTYPEID_1 = case_when(
BUILTTYPEID_1 == 0 ~ "noObraNueva",
BUILTTYPEID_1 == 1 ~ "obraNueva"),
BUILTTYPEID_2 = case_when(
BUILTTYPEID_2 == 0 ~ "noRestaurar",
BUILTTYPEID_2 == 1 ~ "Restaurar"),
BUILTTYPEID_3 = case_when(
BUILTTYPEID_3 == 0 ~ "noSegundaMano",
BUILTTYPEID_3 == 1 ~ "SegundaMano"),
FLOORCLEAN = replace_na(FLOORCLEAN, 0),
CDIS = case_when(
CDIS == 1 ~ "Ciutat-Vella",
CDIS == 2 ~ "Eixample",
CDIS == 3 ~ "Sants-Montjuic",
CDIS == 4 ~ "Les Corts",
CDIS == 5 ~ "Sarrià-Sant Gervasi",
CDIS == 6 ~ "Gràcia",
CDIS == 7 ~ "Horta-Guinardó",
CDIS == 8 ~ "Nou Barris",
CDIS == 9 ~ "Sant Andreu",
CDIS == 10 ~ "Sant Martí"),
RENTA = case_when(
Renta.media.por.hogar < 30000 ~ "Baja",
Renta.media.por.hogar >= 30000 & Renta.media.por.hogar <= 50000 ~ "Media",
Renta.media.por.hogar > 50000 ~ "Alta"
)
) %>%
select(-Renta.media.por.hogar, -Renta.media.por.persona) %>%
mutate(RENTA = as.factor(RENTA)) %>%
na.omit()Antes de entrenar el modelo conviene revisar la estructura de los datos, el tamaño de las clases y la distribución de algunas variables relevantes.
[1] 23334 36
'data.frame': 23334 obs. of 36 variables:
$ UNITPRICE : num 5232 4108 4056 6585 4200 ...
$ CONSTRUCTEDAREA : int 56 74 72 65 70 33 82 98 86 94 ...
$ ROOMNUMBER : int 1 2 3 1 3 1 2 3 3 3 ...
$ BATHNUMBER : int 1 1 1 2 1 1 2 1 1 1 ...
$ HASTERRACE : chr "No" "Si" "No" "Si" ...
$ HASLIFT : chr "Si" "Si" "Si" "No" ...
$ HASAIRCONDITIONING : chr "No" "No" "No" "Si" ...
$ AMENITYID : chr "CocinaSinMuebles" "CocinaMuebles" "CocinaMuebles" "CocinaMuebles" ...
$ HASPARKINGSPACE : chr "No" "No" "No" "No" ...
$ ISPARKINGSPACEINCLUDEDINPRICE: chr "No" "No" "No" "No" ...
$ PARKINGSPACEPRICE : num 1 1 1 1 1 1 1 1 1 1 ...
$ HASNORTHORIENTATION : chr "No" "No" "No" "No" ...
$ HASSOUTHORIENTATION : chr "No" "No" "No" "No" ...
$ HASEASTORIENTATION : chr "No" "No" "No" "No" ...
$ HASWESTORIENTATION : chr "No" "No" "No" "Si" ...
$ HASBOXROOM : chr "No" "No" "No" "No" ...
$ HASWARDROBE : chr "No" "No" "No" "No" ...
$ HASSWIMMINGPOOL : chr "No" "No" "No" "No" ...
$ HASDOORMAN : chr "No" "No" "No" "No" ...
$ HASGARDEN : chr "No" "No" "No" "No" ...
$ ISDUPLEX : chr "No" "No" "No" "No" ...
$ ISSTUDIO : chr "No" "No" "No" "No" ...
$ ISINTOPFLOOR : chr "No" "No" "No" "No" ...
$ FLOORCLEAN : int 0 4 3 2 0 0 0 6 4 4 ...
$ FLATLOCATIONID : chr "exterior" "noInfo" "exterior" "exterior" ...
$ CADCONSTRUCTIONYEAR : int 2018 1959 1959 1936 1950 1900 2018 1936 1993 1993 ...
$ CADMAXBUILDINGFLOOR : int 5 5 5 6 3 6 5 11 5 5 ...
$ CADDWELLINGCOUNT : int 11 11 11 14 3 7 11 45 44 44 ...
$ BUILTTYPEID_1 : chr "noObraNueva" "noObraNueva" "noObraNueva" "noObraNueva" ...
$ BUILTTYPEID_2 : chr "noRestaurar" "noRestaurar" "noRestaurar" "noRestaurar" ...
$ BUILTTYPEID_3 : chr "SegundaMano" "SegundaMano" "SegundaMano" "SegundaMano" ...
$ DISTANCE_TO_CITY_CENTER : num 1.7 1.75 1.75 1.68 1.66 ...
$ DISTANCE_TO_METRO : num 0.291 0.336 0.332 0.3 0.252 ...
$ DISTANCE_TO_DIAGONAL : num 2.31 2.38 2.44 2.5 2.41 ...
$ CDIS : chr "Ciutat-Vella" "Ciutat-Vella" "Ciutat-Vella" "Ciutat-Vella" ...
$ RENTA : Factor w/ 3 levels "Alta","Baja",..: 2 2 2 2 2 2 2 2 2 2 ...
Alta Baja Media
2408 7749 13177
Alta Baja Media
0.1031971 0.3320905 0.5647124
UNITPRICE CONSTRUCTEDAREA ROOMNUMBER BATHNUMBER
Min. :1210 Min. : 21.00 Min. : 0.000 Min. : 0.000
1st Qu.:3286 1st Qu.: 65.00 1st Qu.: 2.000 1st Qu.: 1.000
Median :4058 Median : 82.00 Median : 3.000 Median : 1.000
Mean :4099 Mean : 94.91 Mean : 2.846 Mean : 1.519
3rd Qu.:4873 3rd Qu.:109.00 3rd Qu.: 3.000 3rd Qu.: 2.000
Max. :6994 Max. :790.00 Max. :12.000 Max. :10.000
PARKINGSPACEPRICE FLOORCLEAN CADCONSTRUCTIONYEAR CADMAXBUILDINGFLOOR
Min. : 1.0 Min. :-1.000 Min. :1588 Min. : 0.000
1st Qu.: 1.0 1st Qu.: 1.000 1st Qu.:1927 1st Qu.: 5.000
Median : 1.0 Median : 2.000 Median :1961 Median : 6.000
Mean : 606.5 Mean : 2.617 Mean :1948 Mean : 6.724
3rd Qu.: 1.0 3rd Qu.: 4.000 3rd Qu.:1973 3rd Qu.: 8.000
Max. :150001.0 Max. :11.000 Max. :2018 Max. :34.000
CADDWELLINGCOUNT DISTANCE_TO_CITY_CENTER DISTANCE_TO_METRO
Min. : 1.00 Min. :0.08033 Min. :0.0007362
1st Qu.: 11.00 1st Qu.:1.31146 1st Qu.:0.1541246
Median : 16.00 Median :2.28058 Median :0.2418701
Mean : 27.19 Mean :2.60691 Mean :0.2616103
3rd Qu.: 29.00 3rd Qu.:3.63917 3rd Qu.:0.3389505
Max. :505.00 Max. :8.61172 Max. :4.0996294
DISTANCE_TO_DIAGONAL
Min. :0.005767
1st Qu.:0.921281
Median :1.661283
Mean :1.748727
3rd Qu.:2.319904
Max. :6.811647
Separamos los datos en dos conjuntos:
La partición se hace de forma estratificada para conservar aproximadamente la proporción de clases en ambos subconjuntos.
Alta Baja Media
0.1032192 0.3321013 0.5646794
Alta Baja Media
0.1031083 0.3320472 0.5648446
import pandas as pd
import numpy as np
# Eliminamos filas con posibles valores perdidos
pyBCN = pyBCN.dropna().copy()
# Variable objetivo
y = pyBCN["RENTA"].astype(str)
X = pyBCN.drop(columns=["RENTA"])
# One-hot encoding para variables categóricas.
# A diferencia de LabelEncoder, one-hot evita imponer un orden artificial entre categorías.
X = pd.get_dummies(X, drop_first=False)
print(X.shape)(23334, 68)
RENTA
Media 0.564712
Baja 0.332091
Alta 0.103197
Name: proportion, dtype: float64
Un primer modelo se puede entrenar con parámetros razonables. En R se usa randomForest; en Python se usa RandomForestClassifier de scikit-learn.
Call:
randomForest(formula = RENTA ~ ., data = rtrain, ntree = 500, importance = TRUE)
Type of random forest: classification
Number of trees: 500
No. of variables tried at each split: 5
OOB estimate of error rate: 8.38%
Confusion matrix:
Alta Baja Media class.error
Alta 1550 2 375 0.19564089
Baja 3 5536 661 0.10709677
Media 183 340 10019 0.04961108
RandomForestClassifier(class_weight='balanced', n_estimators=500, n_jobs=-1,
random_state=1994)In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. | n_estimators | 500 | |
| criterion | 'gini' | |
| max_depth | None | |
| min_samples_split | 2 | |
| min_samples_leaf | 1 | |
| min_weight_fraction_leaf | 0.0 | |
| max_features | 'sqrt' | |
| max_leaf_nodes | None | |
| min_impurity_decrease | 0.0 | |
| bootstrap | True | |
| oob_score | False | |
| n_jobs | -1 | |
| random_state | 1994 | |
| verbose | 0 | |
| warm_start | False | |
| class_weight | 'balanced' | |
| ccp_alpha | 0.0 | |
| max_samples | None | |
| monotonic_cst | None |
RandomForestClassifier(class_weight='balanced', n_estimators=500, n_jobs=-1,
random_state=1994)In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. | n_estimators | 500 | |
| criterion | 'gini' | |
| max_depth | None | |
| min_samples_split | 2 | |
| min_samples_leaf | 1 | |
| min_weight_fraction_leaf | 0.0 | |
| max_features | 'sqrt' | |
| max_leaf_nodes | None | |
| min_impurity_decrease | 0.0 | |
| bootstrap | True | |
| oob_score | False | |
| n_jobs | -1 | |
| random_state | 1994 | |
| verbose | 0 | |
| warm_start | False | |
| class_weight | 'balanced' | |
| ccp_alpha | 0.0 | |
| max_samples | None | |
| monotonic_cst | None |
La evaluación principal se realiza sobre el conjunto de test. La matriz de confusión permite identificar qué clases se predicen correctamente y qué clases se confunden entre sí.
Confusion Matrix and Statistics
Reference
Prediction Alta Baja Media
Alta 1927 0 0
Baja 0 6200 0
Media 0 0 10542
Overall Statistics
Accuracy : 1
95% CI : (0.9998, 1)
No Information Rate : 0.5647
P-Value [Acc > NIR] : < 2.2e-16
Kappa : 1
Mcnemar's Test P-Value : NA
Statistics by Class:
Class: Alta Class: Baja Class: Media
Sensitivity 1.0000 1.0000 1.0000
Specificity 1.0000 1.0000 1.0000
Pos Pred Value 1.0000 1.0000 1.0000
Neg Pred Value 1.0000 1.0000 1.0000
Prevalence 0.1032 0.3321 0.5647
Detection Rate 0.1032 0.3321 0.5647
Detection Prevalence 0.1032 0.3321 0.5647
Balanced Accuracy 1.0000 1.0000 1.0000
Confusion Matrix and Statistics
Reference
Prediction Alta Baja Media
Alta 377 0 35
Baja 1 1398 86
Media 103 151 2514
Overall Statistics
Accuracy : 0.9194
95% CI : (0.9112, 0.9271)
No Information Rate : 0.5648
P-Value [Acc > NIR] : < 2.2e-16
Kappa : 0.8535
Mcnemar's Test P-Value : 2.542e-11
Statistics by Class:
Class: Alta Class: Baja Class: Media
Sensitivity 0.78378 0.9025 0.9541
Specificity 0.99163 0.9721 0.8749
Pos Pred Value 0.91505 0.9414 0.9082
Neg Pred Value 0.97555 0.9525 0.9362
Prevalence 0.10311 0.3320 0.5648
Detection Rate 0.08081 0.2997 0.5389
Detection Prevalence 0.08832 0.3183 0.5934
Balanced Accuracy 0.88771 0.9373 0.9145
CM <- caret::confusionMatrix(pred_test_rf, rtest$RENTA)
CM_df <- as.data.frame(CM$table)
plot_cm <- ggplot(CM_df, aes(x = Reference, y = Prediction, fill = Freq)) +
geom_tile() +
geom_text(aes(label = Freq), size = 5) +
scale_fill_gradient(low = "white", high = "#0077C8") +
labs(
title = "Matriz de confusión - Random Forest",
x = "Clase real",
y = "Clase predicha"
) +
theme_minimal()
plot_cmAccuracy train: 1.0
Accuracy test: 0.9096
Classification report test:
precision recall f1-score support
Alta 0.88 0.81 0.84 482
Baja 0.94 0.88 0.91 1550
Media 0.90 0.94 0.92 2635
accuracy 0.91 4667
macro avg 0.91 0.88 0.89 4667
weighted avg 0.91 0.91 0.91 4667
<sklearn.metrics._plot.confusion_matrix.ConfusionMatrixDisplay object at 0x000001FDFFF79090>
La validación cruzada permite estimar de forma más estable el rendimiento del modelo. En lugar de depender de una única partición train/test, se divide el entrenamiento en varios folds.
Random Forest
18669 samples
35 predictor
3 classes: 'Alta', 'Baja', 'Media'
No pre-processing
Resampling: Cross-Validated (5 fold)
Summary of sample sizes: 14935, 14936, 14936, 14935, 14934
Resampling results across tuning parameters:
mtry logLoss AUC prAUC Accuracy Kappa Mean_F1
2 0.3837728 0.9573015 0.9003123 0.8591246 0.7374143 0.8073345
23 0.1874353 0.9888396 0.8540817 0.9311686 0.8761303 0.9148925
45 0.1792408 0.9893154 0.7308102 0.9360437 0.8851472 0.9206214
Mean_Sensitivity Mean_Specificity Mean_Pos_Pred_Value Mean_Neg_Pred_Value
0.7713629 0.9034198 0.8703969 0.9221782
0.9086395 0.9559249 0.9219028 0.9600771
0.9153453 0.9595549 0.9265099 0.9625254
Mean_Precision Mean_Recall Mean_Detection_Rate Mean_Balanced_Accuracy
0.8703969 0.7713629 0.2863749 0.8373914
0.9219028 0.9086395 0.3103895 0.9322822
0.9265099 0.9153453 0.3120146 0.9374501
Accuracy was used to select the optimal model using the largest value.
The final value used for the model was mtry = 45.
from sklearn.model_selection import StratifiedKFold, cross_val_score
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=1994)
rf_cv_py = RandomForestClassifier(
n_estimators=300,
random_state=1994,
n_jobs=-1,
class_weight="balanced"
)
scores = cross_val_score(
rf_cv_py,
pyX_train,
pyy_train,
cv=cv,
scoring="accuracy",
n_jobs=-1
)
print("Accuracy por fold:", np.round(scores, 4))Accuracy por fold: [0.9006 0.9138 0.9215 0.9202 0.9113]
Accuracy medio: 0.9135
Desviación típica: 0.0075
Los hiperparámetros más importantes en Random Forest son:
ntree / n_estimators: número de árboles.mtry / max_features: número de variables candidatas en cada división.min.node.size / min_samples_leaf: tamaño mínimo de los nodos terminales.max.depth / max_depth: profundidad máxima de cada árbol.Un bosque con muchos árboles reduce la varianza, pero aumenta el coste computacional. El parámetro más crítico suele ser mtry o max_features, porque controla la diversidad entre árboles.
Random Forest
18669 samples
35 predictor
3 classes: 'Alta', 'Baja', 'Media'
No pre-processing
Resampling: Cross-Validated (5 fold)
Summary of sample sizes: 14935, 14936, 14936, 14935, 14934
Resampling results across tuning parameters:
mtry logLoss AUC prAUC Accuracy Kappa Mean_F1
3 0.3033418 0.9732015 0.9343610 0.8942629 0.8065904 0.8643059
5 0.2528195 0.9811333 0.9483121 0.9146172 0.8453745 0.8945864
8 0.2245533 0.9847436 0.9490133 0.9205095 0.8564924 0.9025094
12 0.2078128 0.9867971 0.9342018 0.9254907 0.8657207 0.9093323
Mean_Sensitivity Mean_Specificity Mean_Pos_Pred_Value Mean_Neg_Pred_Value
0.8408619 0.9293819 0.8952869 0.9404042
0.8817297 0.9441248 0.9095907 0.9511028
0.8929585 0.9485128 0.9133261 0.9540793
0.9021088 0.9519407 0.9174107 0.9568204
Mean_Precision Mean_Recall Mean_Detection_Rate Mean_Balanced_Accuracy
0.8952869 0.8408619 0.2980876 0.8851219
0.9095907 0.8817297 0.3048724 0.9129272
0.9133261 0.8929585 0.3068365 0.9207357
0.9174107 0.9021088 0.3084969 0.9270248
Accuracy was used to select the optimal model using the largest value.
The final value used for the model was mtry = 12.
from sklearn.model_selection import GridSearchCV
param_grid = {
"n_estimators": [300, 500],
"max_features": ["sqrt", "log2", None],
"min_samples_leaf": [1, 5, 10],
"max_depth": [None, 8, 15]
}
rf_grid_py = GridSearchCV(
estimator=RandomForestClassifier(
random_state=1994,
n_jobs=-1,
class_weight="balanced"
),
param_grid=param_grid,
cv=cv,
scoring="accuracy",
n_jobs=-1
)
rf_grid_py.fit(pyX_train, pyy_train)GridSearchCV(cv=StratifiedKFold(n_splits=5, random_state=1994, shuffle=True),
estimator=RandomForestClassifier(class_weight='balanced',
n_jobs=-1, random_state=1994),
n_jobs=-1,
param_grid={'max_depth': [None, 8, 15],
'max_features': ['sqrt', 'log2', None],
'min_samples_leaf': [1, 5, 10],
'n_estimators': [300, 500]},
scoring='accuracy')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. | estimator | RandomForestC...om_state=1994) | |
| param_grid | {'max_depth': [None, 8, ...], 'max_features': ['sqrt', 'log2', ...], 'min_samples_leaf': [1, 5, ...], 'n_estimators': [300, 500]} | |
| scoring | 'accuracy' | |
| n_jobs | -1 | |
| refit | True | |
| cv | StratifiedKFo... shuffle=True) | |
| verbose | 0 | |
| pre_dispatch | '2*n_jobs' | |
| error_score | nan | |
| return_train_score | False |
RandomForestClassifier(class_weight='balanced', max_features=None,
n_estimators=500, n_jobs=-1, random_state=1994)| n_estimators | 500 | |
| criterion | 'gini' | |
| max_depth | None | |
| min_samples_split | 2 | |
| min_samples_leaf | 1 | |
| min_weight_fraction_leaf | 0.0 | |
| max_features | None | |
| max_leaf_nodes | None | |
| min_impurity_decrease | 0.0 | |
| bootstrap | True | |
| oob_score | False | |
| n_jobs | -1 | |
| random_state | 1994 | |
| verbose | 0 | |
| warm_start | False | |
| class_weight | 'balanced' | |
| ccp_alpha | 0.0 | |
| max_samples | None | |
| monotonic_cst | None |
Mejores parámetros: {'max_depth': None, 'max_features': None, 'min_samples_leaf': 1, 'n_estimators': 500}
Mejor accuracy CV: 0.9361
La importancia de variables responde a la pregunta:
¿Qué variables han contribuido más al poder predictivo del modelo?
En Random Forest hay varias formas de medir importancia:
En randomForest, al activar importance = TRUE, se obtiene una matriz de importancia. Para clasificación multiclase, pueden aparecer columnas específicas por clase y medidas globales como MeanDecreaseAccuracy y MeanDecreaseGini.
imp_top <- imp_matrix %>%
arrange(desc(MeanDecreaseGini)) %>%
slice(1:20) %>%
mutate(Variable = reorder(Variable, MeanDecreaseGini))
ggplot(imp_top, aes(x = Variable, y = MeanDecreaseGini)) +
geom_col(fill = "#0077C8") +
coord_flip() +
labs(
title = "Top 20 variables importantes - Mean Decrease Gini",
x = "Variable",
y = "Importancia"
) +
theme_minimal() Variable Importance
11 DISTANCE_TO_DIAGONAL 0.145674
9 DISTANCE_TO_CITY_CENTER 0.101951
67 CDIS_Sarrià-Sant Gervasi 0.078778
1 CONSTRUCTEDAREA 0.069157
0 UNITPRICE 0.068790
58 CDIS_Ciutat-Vella 0.066791
6 CADCONSTRUCTIONYEAR 0.041500
10 DISTANCE_TO_METRO 0.040757
59 CDIS_Eixample 0.039429
8 CADDWELLINGCOUNT 0.035114
3 BATHNUMBER 0.026456
7 CADMAXBUILDINGFLOOR 0.026411
2 ROOMNUMBER 0.019426
5 FLOORCLEAN 0.016596
14 HASLIFT_No 0.016070
60 CDIS_Gràcia 0.014980
15 HASLIFT_Si 0.014639
62 CDIS_Les Corts 0.014250
66 CDIS_Sants-Montjuic 0.010680
65 CDIS_Sant Martí 0.008724
La importancia por permutación es más intuitiva que la importancia por impureza. El procedimiento es:
Si al permutar una variable el rendimiento cae mucho, esa variable era importante.
from sklearn.inspection import permutation_importance
perm = permutation_importance(
rf_basico_py,
pyX_test,
pyy_test,
n_repeats=10,
random_state=1994,
n_jobs=-1,
scoring="accuracy"
)
perm_df = pd.DataFrame({
"Variable": pyX_test.columns,
"ImportanceMean": perm.importances_mean,
"ImportanceStd": perm.importances_std
}).sort_values("ImportanceMean", ascending=False)
print(perm_df.head(20)) Variable ImportanceMean ImportanceStd
11 DISTANCE_TO_DIAGONAL 0.142233 0.004354
58 CDIS_Ciutat-Vella 0.070431 0.002933
9 DISTANCE_TO_CITY_CENTER 0.039940 0.002536
67 CDIS_Sarrià-Sant Gervasi 0.028391 0.001856
59 CDIS_Eixample 0.022370 0.003592
0 UNITPRICE 0.020934 0.002006
1 CONSTRUCTEDAREA 0.018513 0.001304
10 DISTANCE_TO_METRO 0.011228 0.001167
65 CDIS_Sant Martí 0.008207 0.000678
6 CADCONSTRUCTIONYEAR 0.007114 0.000908
8 CADDWELLINGCOUNT 0.006557 0.000930
7 CADMAXBUILDINGFLOOR 0.005014 0.000895
62 CDIS_Les Corts 0.005014 0.000451
3 BATHNUMBER 0.004414 0.001412
60 CDIS_Gràcia 0.004393 0.000600
66 CDIS_Sants-Montjuic 0.004178 0.000699
64 CDIS_Sant Andreu 0.002571 0.000583
14 HASLIFT_No 0.001821 0.000940
15 HASLIFT_Si 0.001500 0.000924
2 ROOMNUMBER 0.001414 0.001089
Aunque Random Forest es un modelo de conjunto, internamente está formado por muchos árboles individuales. Extraer uno de ellos ayuda a entender la lógica local de algunas reglas, aunque no debe confundirse con la explicación completa del bosque.
Un árbol individual del bosque:
Por tanto, extraer un árbol sirve para explicar el mecanismo interno, no para sustituir la interpretación global del Random Forest.
# Para visualizar un árbol de forma más clara, entrenamos un árbol pequeño auxiliar.
# Este árbol NO es el Random Forest completo, sino una representación interpretativa sencilla.
library(rpart)
set.seed(1994)
arbol_auxiliar <- rpart(
RENTA ~ .,
data = rtrain,
control = rpart.control(maxdepth = 3, minbucket = 50, cp = 0.001)
)
rpart.plot(arbol_auxiliar, main = "Árbol auxiliar para interpretación")Profundidad del árbol: 32
Número de hojas: 2473
|--- HASDOORMAN_Si <= 0.50
| |--- CDIS_Sant Andreu <= 0.50
| | |--- CDIS_Ciutat-Vella <= 0.50
| | | |--- DISTANCE_TO_DIAGONAL <= 2.27
| | | | |--- truncated branch of depth 29
| | | |--- DISTANCE_TO_DIAGONAL > 2.27
| | | | |--- truncated branch of depth 27
| | |--- CDIS_Ciutat-Vella > 0.50
| | | |--- ROOMNUMBER <= 4.50
| | | | |--- truncated branch of depth 19
| | | |--- ROOMNUMBER > 4.50
| | | | |--- truncated branch of depth 6
| |--- CDIS_Sant Andreu > 0.50
| | |--- CADCONSTRUCTIONYEAR <= 1958.50
| | | |--- HASSOUTHORIENTATION_Si <= 0.50
| | | | |--- truncated branch of depth 4
| | | |--- HASSOUTHORIENTATION_Si > 0.50
| | | | |--- truncated branch of depth 5
| | |--- CADCONSTRUCTIONYEAR > 1958.50
| | | |--- DISTANCE_TO_DIAGONAL <= 3.88
| | | | |--- truncated branch of depth 17
| | | |--- DISTANCE_TO_DIAGONAL > 3.88
| | | | |--- truncated branch of depth 15
|--- HASDOORMAN_Si > 0.50
| |--- CONSTRUCTEDAREA <= 125.50
| | |--- DISTANCE_TO_CITY_CENTER <= 1.99
| | | |--- CADCONSTRUCTIONYEAR <= 1978.50
| | | | |--- truncated branch of depth 8
| | | |--- CADCONSTRUCTIONYEAR > 1978.50
| | | | |--- truncated branch of depth 12
| | |--- DISTANCE_TO_CITY_CENTER > 1.99
| | | |--- DISTANCE_TO_CITY_CENTER <= 5.10
| | | | |--- truncated branch of depth 19
| | | |--- DISTANCE_TO_CITY_CENTER > 5.10
| | | | |--- truncated branch of depth 5
| |--- CONSTRUCTEDAREA > 125.50
| | |--- CDIS_Ciutat-Vella <= 0.50
| | | |--- FLOORCLEAN <= 6.50
| | | | |--- truncated branch of depth 16
| | | |--- FLOORCLEAN > 6.50
| | | | |--- truncated branch of depth 12
| | |--- CDIS_Ciutat-Vella > 0.50
| | | |--- DISTANCE_TO_CITY_CENTER <= 0.46
| | | | |--- truncated branch of depth 3
| | | |--- DISTANCE_TO_CITY_CENTER > 0.46
| | | | |--- truncated branch of depth 3
Los Partial Dependence Plots muestran cómo cambia la predicción media del modelo cuando modificamos una variable, manteniendo el resto de variables según su distribución observada.
Sirven para responder preguntas como:
¿Cómo cambia la probabilidad de renta alta cuando aumenta la distancia al centro?
# Seleccionamos una variable numérica importante.
# Si DISTANCE_TO_CITY_CENTER está disponible, suele ser una buena candidata.
partial_rf <- partial(
rf_basico,
pred.var = "DISTANCE_TO_CITY_CENTER",
train = rtrain,
which.class = "Alta",
prob = TRUE
)
autoplot(partial_rf) +
labs(
title = "Partial Dependence Plot",
subtitle = "Efecto medio de DISTANCE_TO_CITY_CENTER sobre la probabilidad de RENTA Alta",
x = "Distancia al centro",
y = "Probabilidad media predicha"
) +
theme_minimal()from sklearn.inspection import PartialDependenceDisplay
# Elegimos una variable si existe tras el one-hot encoding
candidate_var = "DISTANCE_TO_CITY_CENTER"
if candidate_var in pyX_train.columns:
PartialDependenceDisplay.from_estimator(
rf_basico_py,
pyX_train,
features=[candidate_var],
target="Alta"
)
plt.title("Partial Dependence Plot - DISTANCE_TO_CITY_CENTER")
plt.show()
else:
print(f"La variable {candidate_var} no está disponible en pyX_train.")Los SHAP values son una técnica de interpretabilidad basada en teoría de juegos. La idea es repartir la predicción de un modelo entre las variables explicativas.
Para una observación concreta, la predicción puede expresarse de forma aproximada como:
\[\hat{f}(x) = \phi_0 + \phi_1 + \phi_2 + \cdots + \phi_p\]
donde:
En clasificación multiclase, los SHAP values se calculan por clase. Por ejemplo, podemos interpretar qué variables empujan la predicción hacia RENTA = Alta.
La interpretación global resume qué variables tienen mayor impacto medio sobre las predicciones del modelo.
library(iml)
library(future)
future::plan(future::sequential)
options(future.globals.maxSize = 2 * 1024^3)
# Para SHAP en R usamos una muestra pequeña.
# Así evitamos que iml copie objetos enormes internamente.
set.seed(1994)
X_train_r <- rtrain %>% select(-RENTA)
y_train_r <- rtrain$RENTA
idx_shap_r <- sample(
seq_len(nrow(X_train_r)),
size = min(300, nrow(X_train_r))
)
X_shap_r <- X_train_r[idx_shap_r, , drop = FALSE]
y_shap_r <- y_train_r[idx_shap_r]
predict_fun_alta <- function(model, newdata) {
predict(model, newdata, type = "prob")[, "Alta"]
}
predictor_alta <- Predictor$new(
model = rf_basico,
data = X_shap_r,
y = as.numeric(y_shap_r == "Alta"),
predict.function = predict_fun_alta,
type = "prob"
)# FeatureImp no es SHAP estrictamente, pero da una lectura global complementaria
# basada en pérdida por permutación para la clase Alta.
set.seed(1994)
effect_global <- FeatureImp$new(
predictor_alta,
loss = "ce",
n.repetitions = 3
)
plot(effect_global) +
labs(title = "Importancia global por permutación para la clase Alta")# SHAP local para una observación concreta.
# Elegimos la primera observación del conjunto de test.
set.seed(1994)
shap_obs_1 <- Shapley$new(
predictor_alta,
x.interest = rtest[1, setdiff(names(rtest), "RENTA"), drop = FALSE],
sample.size = 100
)
plot(shap_obs_1) +
labs(title = "SHAP local para una vivienda - Clase Alta")# Si no tienes shap instalado, ejecuta previamente:
# pip install shap
import shap
# Para acelerar el cálculo, usamos una muestra del conjunto de test.
X_shap = pyX_test.sample(min(300, len(pyX_test)), random_state=1994)
explainer = shap.TreeExplainer(rf_basico_py)
shap_values = explainer.shap_values(X_shap)
# En clasificación multiclase, shap_values suele ser una lista:
# una matriz de SHAP values por cada clase.
print(rf_basico_py.classes_)En el gráfico SHAP de puntos:
Ejemplo de interpretación para la clase Alta:
Si
DISTANCE_TO_CITY_CENTERaparece con puntos rojos a la izquierda, significaría que valores altos de distancia al centro reducen la probabilidad de renta alta. Si aparecen a la derecha, significaría que la aumentan.
| Criterio | Árbol de decisión | Random Forest |
|---|---|---|
| Interpretabilidad | Muy alta | Media |
| Rendimiento predictivo | Medio | Alto |
| Riesgo de sobreajuste | Alto | Menor |
| Estabilidad | Baja | Alta |
| Coste computacional | Bajo | Medio/alto |
| Importancia de variables | Sí | Sí, más robusta |
| Extracción de reglas | Directa | Parcial, árbol por árbol |
La idea fundamental es que Random Forest sacrifica parte de la interpretabilidad directa de un único árbol a cambio de mayor estabilidad y mejor rendimiento predictivo.
Al usar Random Forest conviene:
En este documento se ha construido un flujo completo de modelización con Random Forest en R y Python. Se han cubierto las fases principales:
Random Forest es especialmente útil cuando se busca un modelo robusto y potente, pero sigue siendo necesario acompañarlo de técnicas de interpretación para justificar sus predicciones y entender qué variables están guiando las decisiones del modelo.
R version 4.5.1 (2025-06-13 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 26200)
Matrix products: default
LAPACK version 3.12.1
locale:
[1] LC_COLLATE=Spanish_Spain.utf8 LC_CTYPE=Spanish_Spain.utf8
[3] LC_MONETARY=Spanish_Spain.utf8 LC_NUMERIC=C
[5] LC_TIME=Spanish_Spain.utf8
time zone: Europe/Madrid
tzcode source: internal
attached base packages:
[1] parallel splines stats4 grid tools compiler stats
[8] graphics grDevices utils datasets methods base
other attached packages:
[1] pkgconfig_2.0.3 ModelMetrics_1.2.2.2 xfun_0.56
[4] checkmate_2.3.4 prodlim_2025.04.28 nlme_3.1-168
[7] Rcpp_1.1.1 class_7.3-23 backports_1.5.0
[10] png_0.1-8 evaluate_1.0.5 R6_2.6.1
[13] lava_1.8.2 ipred_0.9-15 htmltools_0.5.9
[16] pillar_1.11.1 tibble_3.3.1 gtable_0.3.6
[19] stringi_1.8.7 lubridate_1.9.5 codetools_0.2-20
[22] parallelly_1.46.1 glue_1.8.0 recipes_1.3.2
[25] gower_1.0.2 foreach_1.5.2 listenv_0.10.1
[28] jsonlite_2.0.0 Matrix_1.7-4 hardhat_1.4.3
[31] vctrs_0.7.1 stringr_1.6.0 proxy_0.4-29
[34] future.apply_1.20.2 rstudioapi_0.18.0 otel_0.2.0
[37] generics_0.1.4 rmarkdown_2.30 cli_3.6.6
[40] MASS_7.3-65 iterators_1.0.14 scales_1.4.0
[43] globals_0.19.1 e1071_1.7-17 nnet_7.3-20
[46] Metrics_0.1.4 MLmetrics_1.1.3 purrr_1.2.2
[49] withr_3.0.2 RColorBrewer_1.1-3 plyr_1.8.9
[52] reticulate_1.45.0 htmlwidgets_1.6.4 labeling_0.4.3
[55] knitr_1.51 data.table_1.18.2.1 yaml_2.3.12
[58] rlang_1.2.0 magrittr_2.0.4 ROCR_1.0-12
[61] survival_3.8-6 lifecycle_1.0.5 timechange_0.4.0
[64] digest_0.6.39 pROC_1.19.0.1 fastmap_1.2.0
[67] S7_0.2.1 farver_2.1.2 timeDate_4052.112
[70] tidyselect_1.2.1 future_1.70.0 reshape2_1.4.5
[73] rpart.plot_3.1.4 rpart_4.1.24 iml_0.11.4
[76] pdp_0.8.3 vip_0.4.6 ranger_0.18.0
[79] randomForest_4.7-1.2 caret_7.0-1 lattice_0.22-9
[82] ggplot2_4.0.3 tidyr_1.3.2 dplyr_1.2.1
Aquesta web està creada por Dante Conti y Sergi Ramírez, (c) 2026
---
title: "Random Forest"
author: "Dante Conti, Sergi Ramirez, (c) IDEAI"
format:
html:
theme: cosmo
toc: true
toc-depth: 3
number-sections: true
code-fold: show
code-summary: "Mostrar código"
embed-resources: true
code-tools: true
df-print: paged
execute:
echo: true
warning: false
message: false
error: false
---
# Descripción del problema
En este documento se estudia el algoritmo **Random Forest** aplicado a un problema de **clasificación supervisada**. El objetivo será predecir el nivel de renta del entorno de una vivienda de Barcelona (`RENTA`) a partir de características del inmueble, del edificio y de su localización.
La base de datos procede de anuncios de vivienda de Idealista y se ha enriquecido con información de renta media por hogar/persona a nivel de sección censal. A partir de la renta media por hogar se construye una variable categórica de tres niveles:
- **Baja**: renta media por hogar inferior a 30.000 €.
- **Media**: renta media por hogar entre 30.000 € y 50.000 €.
- **Alta**: renta media por hogar superior a 50.000 €.
Por tanto, el problema se puede formular como:
$$Y = f(X_1, X_2, \ldots, X_p) + \varepsilon$$
donde:
- $Y$ es la variable objetivo `RENTA`.
- $(X_1, X_2, \ldots, X_p)$ son las variables explicativas del inmueble.
- $f$ es una función no lineal aprendida mediante un conjunto de árboles de decisión.
# Introducción teórica a Random Forest
## De un árbol de decisión a un bosque aleatorio
Un **árbol de decisión** construye reglas del tipo:
> Si `DISTANCE_TO_CITY_CENTER < 2.5` y `HASLIFT = Sí`, entonces clasificar como `RENTA = Alta`.
El problema de un único árbol es que suele tener **alta varianza**: pequeñas variaciones en los datos de entrenamiento pueden generar árboles bastante diferentes. Esto puede provocar **sobreajuste**, es decir, un modelo que memoriza demasiado bien el conjunto de entrenamiento pero generaliza peor en datos nuevos.
**Random Forest** reduce este problema entrenando muchos árboles distintos y combinando sus predicciones. En clasificación, cada árbol vota una clase y el bosque devuelve la clase más votada.
$$\hat{y}_{RF} = \operatorname{mode}\{\hat{y}_1, \hat{y}_2, \ldots, \hat{y}_B\}$$
donde:
- $B$ es el número de árboles del bosque.
- $\hat{y}_b$ es la predicción del árbol $b$.
- $\operatorname{mode}$ representa la clase más frecuente entre los votos.
## Dos fuentes de aleatoriedad
Random Forest introduce aleatoriedad de dos formas:
1. **Bootstrap de observaciones**: cada árbol se entrena con una muestra aleatoria con reemplazo del conjunto de entrenamiento.
2. **Selección aleatoria de variables en cada división**: en cada nodo, el árbol no evalúa todas las variables, sino solamente un subconjunto aleatorio de tamaño `mtry` en R o `max_features` en Python.
Esto hace que los árboles sean menos correlacionados entre sí. Cuanto menor sea la correlación entre árboles, más beneficiosa será la agregación.
## Ventajas principales
Random Forest es uno de los algoritmos clásicos más utilizados porque:
- suele ofrecer buen rendimiento predictivo sin requerir demasiada configuración inicial;
- captura relaciones no lineales;
- captura interacciones entre variables;
- funciona bien con variables numéricas y categóricas previamente tratadas;
- proporciona medidas de **importancia de variables**;
- permite extraer árboles individuales para interpretar reglas concretas;
- puede complementarse con herramientas de interpretabilidad como **SHAP values**.
## Limitaciones
También tiene limitaciones importantes:
- es menos interpretable que un único árbol;
- la importancia basada en impureza puede estar sesgada hacia variables con muchos posibles puntos de corte;
- puede ser costoso computacionalmente si se usan muchos árboles;
- no extrapola bien fuera del rango observado en los datos;
- en clasificación desbalanceada puede favorecer la clase mayoritaria si no se ajustan pesos o métricas adecuadas.
# Carga de datos
El siguiente bloque reproduce la carga de datos usada en el ejemplo de árboles de decisión. La versión final de la base de datos se carga directamente desde GitHub.
```{r}
#| label: cargar-datos-final
#| echo: true
#| eval: true
#| warning: false
#| message: false
#| error: false
path <- 'https://raw.githubusercontent.com/ramIA-lab/MLforEducation/refs/heads/main/material/trees_ensambleMethods/idealista18_BCN_conRenta.csv'
BCN <- read.csv2(path)
```
```{r}
#| label: cargar-paquetes-r
#| echo: true
#| warning: false
#| message: false
#| error: false
library(dplyr)
library(tidyr)
library(ggplot2)
library(caret)
library(randomForest)
library(ranger)
library(vip)
library(pdp)
library(iml)
library(rpart.plot)
library(reshape2)
```
# Preprocesamiento de los datos
El preprocesamiento tiene tres objetivos principales:
1. eliminar columnas identificadoras, geométricas o que no se quieren usar como predictores;
2. transformar variables binarias de 0/1 a etiquetas interpretables (`No`/`Si`);
3. construir la variable objetivo `RENTA` a partir de la renta media por hogar.
```{r}
#| label: preprocessing-R
#| echo: true
#| warning: false
#| message: false
#| error: false
BCN <- BCN %>%
select(-X, -PRICE, -LONGITUDE, -LATITUDE, -geometry, -CONSTRUCTIONYEAR,
-ASSETID, -PERIOD, -CUSEC, -CSEC, -CMUN, -CPRO, -CCA, -CUDIS, -CLAU2,
-NPRO, -NCA, -CNUT0, -CNUT1, -CNUT2, -CNUT3, -NMUN, -Shape_Leng,
-Shape_Area, -geometry, -CUMUN, -CADASTRALQUALITYID) %>%
mutate(
across(
.cols = starts_with(c("HAS", "IS")),
.fns = ~ case_when(. == 0 ~ "No", . == 1 ~ "Si"),
.names = "{.col}"),
AMENITYID = case_when(
AMENITYID == 1 ~ "SinMuebleSinCocina",
AMENITYID == 2 ~ "CocinaSinMuebles",
AMENITYID == 3 ~ "CocinaMuebles"),
FLATLOCATIONID = case_when(
FLATLOCATIONID == 1 ~ "exterior",
FLATLOCATIONID == 2 ~ "interior",
.default = "noInfo"),
BUILTTYPEID_1 = case_when(
BUILTTYPEID_1 == 0 ~ "noObraNueva",
BUILTTYPEID_1 == 1 ~ "obraNueva"),
BUILTTYPEID_2 = case_when(
BUILTTYPEID_2 == 0 ~ "noRestaurar",
BUILTTYPEID_2 == 1 ~ "Restaurar"),
BUILTTYPEID_3 = case_when(
BUILTTYPEID_3 == 0 ~ "noSegundaMano",
BUILTTYPEID_3 == 1 ~ "SegundaMano"),
FLOORCLEAN = replace_na(FLOORCLEAN, 0),
CDIS = case_when(
CDIS == 1 ~ "Ciutat-Vella",
CDIS == 2 ~ "Eixample",
CDIS == 3 ~ "Sants-Montjuic",
CDIS == 4 ~ "Les Corts",
CDIS == 5 ~ "Sarrià-Sant Gervasi",
CDIS == 6 ~ "Gràcia",
CDIS == 7 ~ "Horta-Guinardó",
CDIS == 8 ~ "Nou Barris",
CDIS == 9 ~ "Sant Andreu",
CDIS == 10 ~ "Sant Martí"),
RENTA = case_when(
Renta.media.por.hogar < 30000 ~ "Baja",
Renta.media.por.hogar >= 30000 & Renta.media.por.hogar <= 50000 ~ "Media",
Renta.media.por.hogar > 50000 ~ "Alta"
)
) %>%
select(-Renta.media.por.hogar, -Renta.media.por.persona) %>%
mutate(RENTA = as.factor(RENTA)) %>%
na.omit()
```
# Análisis descriptivo
Antes de entrenar el modelo conviene revisar la estructura de los datos, el tamaño de las clases y la distribución de algunas variables relevantes.
```{r}
#| label: descriptiva-basica
#| echo: true
#| warning: false
#| message: false
#| error: false
dim(BCN)
str(BCN)
table(BCN$RENTA)
prop.table(table(BCN$RENTA))
```
```{r}
#| label: grafico-variable-objetivo
#| echo: true
#| warning: false
#| message: false
#| error: false
ggplot(BCN, aes(x = RENTA, fill = RENTA)) +
geom_bar(show.legend = FALSE) +
labs(
title = "Distribución de la variable objetivo",
x = "Nivel de renta",
y = "Número de observaciones"
) +
theme_minimal()
```
```{r}
#| label: descriptiva-numericas
#| echo: true
#| warning: false
#| message: false
#| error: false
BCN %>%
select(where(is.numeric)) %>%
summary()
```
# Separación entrenamiento/test
Separamos los datos en dos conjuntos:
- **train**: datos usados para entrenar el modelo;
- **test**: datos reservados para evaluar la capacidad de generalización.
La partición se hace de forma estratificada para conservar aproximadamente la proporción de clases en ambos subconjuntos.
::: panel-tabset
## R
```{r}
#| label: gestion-datos-R
#| echo: true
#| warning: false
#| message: false
#| error: false
set.seed(1994)
index <- caret::createDataPartition(BCN$RENTA, p = 0.8, list = FALSE)
rtrain <- BCN %>% slice(index)
rtest <- BCN %>% slice(-index)
prop.table(table(rtrain$RENTA))
prop.table(table(rtest$RENTA))
```
## Python
```{python}
#| label: conversor-r-py
#| echo: false
#| warning: false
#| message: false
#| error: false
pyBCN = r.BCN.copy()
```
```{python}
#| label: preprocessing-python
#| echo: true
#| warning: false
#| message: false
#| error: false
import pandas as pd
import numpy as np
# Eliminamos filas con posibles valores perdidos
pyBCN = pyBCN.dropna().copy()
# Variable objetivo
y = pyBCN["RENTA"].astype(str)
X = pyBCN.drop(columns=["RENTA"])
# One-hot encoding para variables categóricas.
# A diferencia de LabelEncoder, one-hot evita imponer un orden artificial entre categorías.
X = pd.get_dummies(X, drop_first=False)
print(X.shape)
print(y.value_counts(normalize=True))
```
```{python}
#| label: gestion-datos-python
#| echo: true
#| warning: false
#| message: false
#| error: false
from sklearn.model_selection import train_test_split
pyX_train, pyX_test, pyy_train, pyy_test = train_test_split(
X, y,
test_size=0.2,
random_state=1994,
stratify=y
)
print(pyX_train.shape, pyX_test.shape)
```
:::
# Modelo Random Forest
## Entrenamiento básico
Un primer modelo se puede entrenar con parámetros razonables. En R se usa `randomForest`; en Python se usa `RandomForestClassifier` de `scikit-learn`.
::: panel-tabset
## R
```{r}
#| label: random-forest-basico-r
#| echo: true
#| warning: false
#| message: false
#| error: false
set.seed(1994)
rf_basico <- randomForest(
RENTA ~ .,
data = rtrain,
ntree = 500,
importance = TRUE
)
rf_basico
```
## Python
```{python}
#| label: random-forest-basico-python
#| echo: true
#| warning: false
#| message: false
#| error: false
from sklearn.ensemble import RandomForestClassifier
rf_basico_py = RandomForestClassifier(
n_estimators=500,
random_state=1994,
n_jobs=-1,
class_weight="balanced"
)
rf_basico_py.fit(pyX_train, pyy_train)
rf_basico_py
```
:::
## Predicción y matriz de confusión
La evaluación principal se realiza sobre el conjunto de test. La matriz de confusión permite identificar qué clases se predicen correctamente y qué clases se confunden entre sí.
::: panel-tabset
## R
```{r}
#| label: prediccion-rf-r
#| echo: true
#| warning: false
#| message: false
#| error: false
pred_train_rf <- predict(rf_basico, rtrain)
pred_test_rf <- predict(rf_basico, rtest)
caret::confusionMatrix(pred_train_rf, rtrain$RENTA)
caret::confusionMatrix(pred_test_rf, rtest$RENTA)
```
```{r}
#| label: plot-confusion-rf-r
#| echo: true
#| warning: false
#| message: false
#| error: false
CM <- caret::confusionMatrix(pred_test_rf, rtest$RENTA)
CM_df <- as.data.frame(CM$table)
plot_cm <- ggplot(CM_df, aes(x = Reference, y = Prediction, fill = Freq)) +
geom_tile() +
geom_text(aes(label = Freq), size = 5) +
scale_fill_gradient(low = "white", high = "#0077C8") +
labs(
title = "Matriz de confusión - Random Forest",
x = "Clase real",
y = "Clase predicha"
) +
theme_minimal()
plot_cm
```
## Python
```{python}
#| label: prediccion-rf-python
#| echo: true
#| warning: false
#| message: false
#| error: false
from sklearn.metrics import classification_report, confusion_matrix, accuracy_score
py_pred_train = rf_basico_py.predict(pyX_train)
py_pred_test = rf_basico_py.predict(pyX_test)
print("Accuracy train:", round(accuracy_score(pyy_train, py_pred_train), 4))
print("Accuracy test:", round(accuracy_score(pyy_test, py_pred_test), 4))
print("\nClassification report test:\n")
print(classification_report(pyy_test, py_pred_test))
```
```{python}
#| label: plot-confusion-rf-python
#| echo: true
#| warning: false
#| message: false
#| error: false
import matplotlib.pyplot as plt
from sklearn.metrics import ConfusionMatrixDisplay
cm = confusion_matrix(pyy_test, py_pred_test, labels=rf_basico_py.classes_)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=rf_basico_py.classes_)
disp.plot(values_format="d")
plt.title("Matriz de confusión - Random Forest")
plt.show()
```
:::
# Validación cruzada
La validación cruzada permite estimar de forma más estable el rendimiento del modelo. En lugar de depender de una única partición train/test, se divide el entrenamiento en varios folds.
::: panel-tabset
## R
```{r}
#| label: crossvalidation-rf-r
#| echo: true
#| warning: false
#| message: false
#| error: false
trControl <- trainControl(
method = "cv",
number = 5,
classProbs = TRUE,
summaryFunction = multiClassSummary,
savePredictions = "final"
)
set.seed(1994)
rf_cv <- train(
RENTA ~ .,
data = rtrain,
method = "rf",
metric = "Accuracy",
trControl = trControl,
ntree = 300
)
rf_cv
```
```{r}
#| label: plot-crossvalidation-rf-r
#| echo: true
#| warning: false
#| message: false
#| error: false
ggplot(rf_cv) +
labs(title = "Validación cruzada de Random Forest") +
theme_minimal()
```
## Python
```{python}
#| label: crossvalidation-rf-python
#| echo: true
#| warning: false
#| message: false
#| error: false
from sklearn.model_selection import StratifiedKFold, cross_val_score
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=1994)
rf_cv_py = RandomForestClassifier(
n_estimators=300,
random_state=1994,
n_jobs=-1,
class_weight="balanced"
)
scores = cross_val_score(
rf_cv_py,
pyX_train,
pyy_train,
cv=cv,
scoring="accuracy",
n_jobs=-1
)
print("Accuracy por fold:", np.round(scores, 4))
print("Accuracy medio:", round(scores.mean(), 4))
print("Desviación típica:", round(scores.std(), 4))
```
:::
# Ajuste de hiperparámetros
Los hiperparámetros más importantes en Random Forest son:
- `ntree` / `n_estimators`: número de árboles.
- `mtry` / `max_features`: número de variables candidatas en cada división.
- `min.node.size` / `min_samples_leaf`: tamaño mínimo de los nodos terminales.
- `max.depth` / `max_depth`: profundidad máxima de cada árbol.
Un bosque con muchos árboles reduce la varianza, pero aumenta el coste computacional. El parámetro más crítico suele ser `mtry` o `max_features`, porque controla la diversidad entre árboles.
::: panel-tabset
## R
```{r}
#| label: hyperparam-rf-r
#| echo: true
#| warning: false
#| message: false
#| error: false
set.seed(1994)
rf_grid <- expand.grid(
mtry = c(3, 5, 8, 12)
)
rf_tuned <- train(
RENTA ~ .,
data = rtrain,
method = "rf",
metric = "Accuracy",
trControl = trControl,
tuneGrid = rf_grid,
ntree = 500
)
rf_tuned
rf_tuned$bestTune
```
```{r}
#| label: plot-hyperparam-rf-r
#| echo: true
#| warning: false
#| message: false
#| error: false
ggplot(rf_tuned) +
labs(title = "Tuning del parámetro mtry") +
theme_minimal()
```
## Python
```{python}
#| label: hyperparam-rf-python
#| echo: true
#| warning: false
#| message: false
#| error: false
from sklearn.model_selection import GridSearchCV
param_grid = {
"n_estimators": [300, 500],
"max_features": ["sqrt", "log2", None],
"min_samples_leaf": [1, 5, 10],
"max_depth": [None, 8, 15]
}
rf_grid_py = GridSearchCV(
estimator=RandomForestClassifier(
random_state=1994,
n_jobs=-1,
class_weight="balanced"
),
param_grid=param_grid,
cv=cv,
scoring="accuracy",
n_jobs=-1
)
rf_grid_py.fit(pyX_train, pyy_train)
print("Mejores parámetros:", rf_grid_py.best_params_)
print("Mejor accuracy CV:", round(rf_grid_py.best_score_, 4))
```
:::
# Importancia de variables
La importancia de variables responde a la pregunta:
> ¿Qué variables han contribuido más al poder predictivo del modelo?
En Random Forest hay varias formas de medir importancia:
1. **Mean Decrease Gini / impureza**: mide cuánto reduce una variable la impureza de los nodos en promedio.
2. **Permutation importance**: mide cuánto empeora el modelo si se permuta aleatoriamente una variable.
3. **SHAP values**: distribuye la predicción de cada observación entre las variables, proporcionando interpretabilidad local y global.
## Matriz de variables importantes en R
En `randomForest`, al activar `importance = TRUE`, se obtiene una matriz de importancia. Para clasificación multiclase, pueden aparecer columnas específicas por clase y medidas globales como `MeanDecreaseAccuracy` y `MeanDecreaseGini`.
::: panel-tabset
## R
```{r}
#| label: matriz-importancia-r
#| echo: true
#| warning: false
#| message: false
#| error: false
imp_matrix <- as.data.frame(rf_basico$importance)
imp_matrix$Variable <- rownames(imp_matrix)
head(imp_matrix[order(-imp_matrix$MeanDecreaseGini), ], 15)
```
```{r}
#| label: plot-importancia-r
#| echo: true
#| warning: false
#| message: false
#| error: false
imp_top <- imp_matrix %>%
arrange(desc(MeanDecreaseGini)) %>%
slice(1:20) %>%
mutate(Variable = reorder(Variable, MeanDecreaseGini))
ggplot(imp_top, aes(x = Variable, y = MeanDecreaseGini)) +
geom_col(fill = "#0077C8") +
coord_flip() +
labs(
title = "Top 20 variables importantes - Mean Decrease Gini",
x = "Variable",
y = "Importancia"
) +
theme_minimal()
```
```{r}
#| label: varimpplot-r
#| echo: true
#| warning: false
#| message: false
#| error: false
varImpPlot(rf_basico, n.var = 20, main = "Importancia de variables - Random Forest")
```
## Python
```{python}
#| label: matriz-importancia-python
#| echo: true
#| warning: false
#| message: false
#| error: false
importances = rf_basico_py.feature_importances_
importance_df = pd.DataFrame({
"Variable": pyX_train.columns,
"Importance": importances
}).sort_values("Importance", ascending=False)
print(importance_df.head(20))
```
```{python}
#| label: plot-importancia-python
#| echo: true
#| warning: false
#| message: false
#| error: false
top_imp = importance_df.head(20).sort_values("Importance")
plt.figure(figsize=(8, 8))
plt.barh(top_imp["Variable"], top_imp["Importance"])
plt.xlabel("Importancia")
plt.title("Top 20 variables importantes - Random Forest")
plt.tight_layout()
plt.show()
```
:::
## Importancia por permutación
La importancia por permutación es más intuitiva que la importancia por impureza. El procedimiento es:
1. calcular el rendimiento del modelo original;
2. permutar aleatoriamente una variable;
3. volver a calcular el rendimiento;
4. medir cuánto cae la métrica.
Si al permutar una variable el rendimiento cae mucho, esa variable era importante.
::: panel-tabset
## R
```{r}
#| label: permutation-importance-r
#| echo: true
#| warning: false
#| message: false
#| error: false
set.seed(1994)
rf_ranger <- ranger(
RENTA ~ .,
data = rtrain,
num.trees = 500,
importance = "permutation",
probability = TRUE,
seed = 1994
)
vip(rf_ranger, num_features = 20) +
labs(title = "Importancia por permutación - ranger") +
theme_minimal()
```
## Python
```{python}
#| label: permutation-importance-python
#| echo: true
#| warning: false
#| message: false
#| error: false
from sklearn.inspection import permutation_importance
perm = permutation_importance(
rf_basico_py,
pyX_test,
pyy_test,
n_repeats=10,
random_state=1994,
n_jobs=-1,
scoring="accuracy"
)
perm_df = pd.DataFrame({
"Variable": pyX_test.columns,
"ImportanceMean": perm.importances_mean,
"ImportanceStd": perm.importances_std
}).sort_values("ImportanceMean", ascending=False)
print(perm_df.head(20))
```
```{python}
#| label: plot-permutation-importance-python
#| echo: true
#| warning: false
#| message: false
#| error: false
perm_top = perm_df.head(20).sort_values("ImportanceMean")
plt.figure(figsize=(8, 8))
plt.barh(perm_top["Variable"], perm_top["ImportanceMean"])
plt.xlabel("Caída media de accuracy")
plt.title("Top 20 variables - Importancia por permutación")
plt.tight_layout()
plt.show()
```
:::
# Extracción de uno de los árboles del bosque
Aunque Random Forest es un modelo de conjunto, internamente está formado por muchos árboles individuales. Extraer uno de ellos ayuda a entender la lógica local de algunas reglas, aunque no debe confundirse con la explicación completa del bosque.
## Interpretación conceptual
Un árbol individual del bosque:
- se ha entrenado con una muestra bootstrap;
- ha usado subconjuntos aleatorios de variables en cada división;
- representa solo una parte del comportamiento total del modelo;
- puede ser visualizado como un árbol de decisión clásico.
Por tanto, extraer un árbol sirve para explicar el mecanismo interno, no para sustituir la interpretación global del Random Forest.
::: panel-tabset
## R
```{r}
#| label: extraer-arbol-r
#| echo: true
#| warning: false
#| message: false
#| error: false
# Extraemos el árbol número 1 del Random Forest.
# getTree devuelve la estructura interna del árbol.
arbol_1 <- getTree(rf_basico, k = 1, labelVar = TRUE)
head(arbol_1, 20)
```
```{r}
#| label: entrenar-arbol-equivalente-r
#| echo: true
#| warning: false
#| message: false
#| error: false
# Para visualizar un árbol de forma más clara, entrenamos un árbol pequeño auxiliar.
# Este árbol NO es el Random Forest completo, sino una representación interpretativa sencilla.
library(rpart)
set.seed(1994)
arbol_auxiliar <- rpart(
RENTA ~ .,
data = rtrain,
control = rpart.control(maxdepth = 3, minbucket = 50, cp = 0.001)
)
rpart.plot(arbol_auxiliar, main = "Árbol auxiliar para interpretación")
```
## Python
```{python}
#| label: extraer-arbol-python
#| echo: true
#| warning: false
#| message: false
#| error: false
from sklearn.tree import plot_tree, export_text
# Extraemos el primer árbol del bosque
one_tree = rf_basico_py.estimators_[0]
print("Profundidad del árbol:", one_tree.get_depth())
print("Número de hojas:", one_tree.get_n_leaves())
```
```{python}
#| label: texto-arbol-python
#| echo: true
#| warning: false
#| message: false
#| error: false
# Exportamos solamente los primeros niveles para que sea legible
rules = export_text(
one_tree,
feature_names=list(pyX_train.columns),
max_depth=3
)
print(rules)
```
```{python}
#| label: plot-arbol-extraido-python
#| echo: true
#| warning: false
#| message: false
#| error: false
plt.figure(figsize=(18, 10))
plot_tree(
one_tree,
feature_names=list(pyX_train.columns),
class_names=list(rf_basico_py.classes_),
max_depth=3,
filled=True,
rounded=True,
fontsize=8
)
plt.title("Primer árbol extraído del Random Forest. Se muestran solo 3 niveles")
plt.show()
```
:::
# Interpretación mediante Partial Dependence Plots
Los **Partial Dependence Plots** muestran cómo cambia la predicción media del modelo cuando modificamos una variable, manteniendo el resto de variables según su distribución observada.
Sirven para responder preguntas como:
> ¿Cómo cambia la probabilidad de renta alta cuando aumenta la distancia al centro?
::: panel-tabset
## R
```{r}
#| label: pdp-r
#| echo: true
#| warning: false
#| message: false
#| error: false
# Seleccionamos una variable numérica importante.
# Si DISTANCE_TO_CITY_CENTER está disponible, suele ser una buena candidata.
partial_rf <- partial(
rf_basico,
pred.var = "DISTANCE_TO_CITY_CENTER",
train = rtrain,
which.class = "Alta",
prob = TRUE
)
autoplot(partial_rf) +
labs(
title = "Partial Dependence Plot",
subtitle = "Efecto medio de DISTANCE_TO_CITY_CENTER sobre la probabilidad de RENTA Alta",
x = "Distancia al centro",
y = "Probabilidad media predicha"
) +
theme_minimal()
```
## Python
```{python}
#| label: pdp-python
#| echo: true
#| warning: false
#| message: false
#| error: false
from sklearn.inspection import PartialDependenceDisplay
# Elegimos una variable si existe tras el one-hot encoding
candidate_var = "DISTANCE_TO_CITY_CENTER"
if candidate_var in pyX_train.columns:
PartialDependenceDisplay.from_estimator(
rf_basico_py,
pyX_train,
features=[candidate_var],
target="Alta"
)
plt.title("Partial Dependence Plot - DISTANCE_TO_CITY_CENTER")
plt.show()
else:
print(f"La variable {candidate_var} no está disponible en pyX_train.")
```
:::
# Interpretación mediante SHAP values
## ¿Qué son los SHAP values?
Los **SHAP values** son una técnica de interpretabilidad basada en teoría de juegos. La idea es repartir la predicción de un modelo entre las variables explicativas.
Para una observación concreta, la predicción puede expresarse de forma aproximada como:
$$\hat{f}(x) = \phi_0 + \phi_1 + \phi_2 + \cdots + \phi_p$$
donde:
- $\phi_0$ es el valor base del modelo;
- $\phi_j$ es la contribución de la variable $j$;
- si $\phi_j > 0$, esa variable empuja la predicción hacia arriba para una clase concreta;
- si $\phi_j < 0$ , esa variable empuja la predicción hacia abajo para esa clase concreta.
En clasificación multiclase, los SHAP values se calculan por clase. Por ejemplo, podemos interpretar qué variables empujan la predicción hacia `RENTA = Alta`.
## SHAP global
La interpretación global resume qué variables tienen mayor impacto medio sobre las predicciones del modelo.
::: panel-tabset
## R
```{r}
#| label: shap-r-preparacion
#| echo: true
#| warning: false
#| message: false
#| error: false
library(iml)
library(future)
future::plan(future::sequential)
options(future.globals.maxSize = 2 * 1024^3)
# Para SHAP en R usamos una muestra pequeña.
# Así evitamos que iml copie objetos enormes internamente.
set.seed(1994)
X_train_r <- rtrain %>% select(-RENTA)
y_train_r <- rtrain$RENTA
idx_shap_r <- sample(
seq_len(nrow(X_train_r)),
size = min(300, nrow(X_train_r))
)
X_shap_r <- X_train_r[idx_shap_r, , drop = FALSE]
y_shap_r <- y_train_r[idx_shap_r]
predict_fun_alta <- function(model, newdata) {
predict(model, newdata, type = "prob")[, "Alta"]
}
predictor_alta <- Predictor$new(
model = rf_basico,
data = X_shap_r,
y = as.numeric(y_shap_r == "Alta"),
predict.function = predict_fun_alta,
type = "prob"
)
```
```{r}
#| label: shap-r-global
#| echo: true
#| warning: false
#| message: false
#| error: false
# FeatureImp no es SHAP estrictamente, pero da una lectura global complementaria
# basada en pérdida por permutación para la clase Alta.
set.seed(1994)
effect_global <- FeatureImp$new(
predictor_alta,
loss = "ce",
n.repetitions = 3
)
plot(effect_global) +
labs(title = "Importancia global por permutación para la clase Alta")
```
```{r}
#| label: shap-r-local
#| echo: true
#| warning: false
#| message: false
#| error: false
# SHAP local para una observación concreta.
# Elegimos la primera observación del conjunto de test.
set.seed(1994)
shap_obs_1 <- Shapley$new(
predictor_alta,
x.interest = rtest[1, setdiff(names(rtest), "RENTA"), drop = FALSE],
sample.size = 100
)
plot(shap_obs_1) +
labs(title = "SHAP local para una vivienda - Clase Alta")
```
## Python
```{python}
#| label: shap-python
#| echo: true
#| eval: false
#| warning: false
#| message: false
#| error: false
# Si no tienes shap instalado, ejecuta previamente:
# pip install shap
import shap
# Para acelerar el cálculo, usamos una muestra del conjunto de test.
X_shap = pyX_test.sample(min(300, len(pyX_test)), random_state=1994)
explainer = shap.TreeExplainer(rf_basico_py)
shap_values = explainer.shap_values(X_shap)
# En clasificación multiclase, shap_values suele ser una lista:
# una matriz de SHAP values por cada clase.
print(rf_basico_py.classes_)
```
```{python}
#| label: shap-summary-python
#| echo: true
#| eval: false
#| warning: false
#| message: false
#| error: false
# Seleccionamos la clase Alta
class_index = list(rf_basico_py.classes_).index("Alta")
shap.summary_plot(
shap_values[class_index],
X_shap,
plot_type="dot",
show=True
)
```
```{python}
#| label: shap-bar-python
#| echo: true
#| eval: false
#| warning: false
#| message: false
#| error: false
shap.summary_plot(
shap_values[class_index],
X_shap,
plot_type="bar",
show=True
)
```
```{python}
#| label: shap-local-python
#| echo: true
#| eval: false
#| warning: false
#| message: false
#| error: false
# Interpretación local de una observación concreta.
obs_id = 0
shap.force_plot(
explainer.expected_value[class_index],
shap_values[class_index][obs_id, :],
X_shap.iloc[obs_id, :],
matplotlib=True
)
```
:::
## Cómo leer un gráfico SHAP summary plot
En el gráfico SHAP de puntos:
- cada punto representa una observación;
- el eje X representa el impacto SHAP sobre la predicción;
- valores positivos empujan hacia la clase analizada;
- valores negativos alejan de la clase analizada;
- el color representa el valor de la variable: valores altos frente a valores bajos;
- las variables aparecen ordenadas por importancia global media.
Ejemplo de interpretación para la clase `Alta`:
> Si `DISTANCE_TO_CITY_CENTER` aparece con puntos rojos a la izquierda, significaría que valores altos de distancia al centro reducen la probabilidad de renta alta. Si aparecen a la derecha, significaría que la aumentan.
# Comparación entre árbol individual y Random Forest
| Criterio | Árbol de decisión | Random Forest |
|---|---|---|
| Interpretabilidad | Muy alta | Media |
| Rendimiento predictivo | Medio | Alto |
| Riesgo de sobreajuste | Alto | Menor |
| Estabilidad | Baja | Alta |
| Coste computacional | Bajo | Medio/alto |
| Importancia de variables | Sí | Sí, más robusta |
| Extracción de reglas | Directa | Parcial, árbol por árbol |
La idea fundamental es que Random Forest sacrifica parte de la interpretabilidad directa de un único árbol a cambio de mayor estabilidad y mejor rendimiento predictivo.
# Buenas prácticas
Al usar Random Forest conviene:
1. reservar siempre un conjunto de test;
2. usar validación cruzada para comparar configuraciones;
3. revisar matriz de confusión, no solo accuracy;
4. analizar si hay clases desbalanceadas;
5. comparar importancia por impureza y por permutación;
6. usar SHAP para interpretación local y global;
7. no interpretar un solo árbol como si fuera todo el bosque;
8. documentar los hiperparámetros finales.
# Conclusiones
En este documento se ha construido un flujo completo de modelización con Random Forest en R y Python. Se han cubierto las fases principales:
- carga y preprocesamiento de datos;
- análisis descriptivo inicial;
- partición train/test;
- entrenamiento de Random Forest;
- evaluación mediante matriz de confusión y métricas de clasificación;
- validación cruzada;
- ajuste de hiperparámetros;
- matriz y gráficos de importancia de variables;
- extracción de un árbol individual del bosque;
- interpretación mediante Partial Dependence Plots;
- interpretación local y global mediante SHAP values.
Random Forest es especialmente útil cuando se busca un modelo robusto y potente, pero sigue siendo necesario acompañarlo de técnicas de interpretación para justificar sus predicciones y entender qué variables están guiando las decisiones del modelo.
# Sesión
```{r}
#| label: session-info
#| echo: true
#| warning: false
#| message: false
#| error: false
sessionInfo()
```