mlr3 combate real | Clasificación de pacientes con enfermedad hepática según parámetros clínicos (7 métodos de aprendizaje automático de uso común)

55c403e94d9106cc87b54ed625348c9d.png

Preámbulo

El siguiente ejemplo es parte de una conferencia introductoria sobre aprendizaje automático en la Universidad de Munich. El objetivo de este proyecto es crear y comparar una o varias canalizaciones de aprendizaje automático para el problema en cuestión, mientras se realiza un análisis exploratorio y se elaboran los resultados.

Preparar

Para obtener una guía detallada de mlr3, consulte:

libro mlr3 (https://mlr3book.mlr-org.com/index.html)

## 安装与加载所需包
install.packages('mlr3verse')
install.packages('DataExplorer')
install.packages('gridExtra')
library(mlr3verse)
library(dplyr)
library(tidyr)
library(DataExplorer)
library(ggplot2)
library(gridExtra)

Inicialice el generador de números aleatorios con una semilla fija para garantizar la repetibilidad y reduzca la verbosidad del registrador para mantener la salida limpia.

set.seed(7832)
lgr::get_logger("mlr3")$set_threshold("warn")
lgr::get_logger("bbotk")$set_threshold("warn")

En este ejemplo, los autores investigan aplicaciones específicas de algoritmos de aprendizaje automático y aprendices para la detección de enfermedades hepáticas. Por lo tanto, la tarea es 二元分类predecir si un paciente tiene una enfermedad hepática en función de algunas medidas de diagnóstico comunes.

Ejemplo de datos y código para recibir: Me gusta, lea este artículo, compártalo con el círculo de amigos, obtenga 10 Me gusta y guárdelo durante 30 minutos. Tome una captura de pantalla y envíe el ID de WeChat: mzbj0002, o escanee el código QR a continuación. Los miembros VIP de 2022 lo recibirán gratis.

Proyecto VIP Canoe Notes 2022

derechos e intereses:

  1. Datos de muestra y código de todos los tweets en Canoe Notes en 2022 (incluida la mayor parte de 2021).

  2. Canoa Notas Grupo de Intercambio de Investigación Científica .

  3. Compra a mitad de precio 跟着Cell学作图系列合集(tutorial gratis + colección de códigos)|Sigue a Cell para aprender a dibujar una colección de series .

PEAJE:

99¥/persona . Puede agregar WeChat: mzbj0002transferir dinero o dar una recompensa directamente al final del artículo.

60cb072bf217dd8e100e92e88195efbd.png

Datos de enfermedades hepáticas en la India

# Importing data
data("ilpd", package = "mlr3data")

583Contiene datos recopilados sobre pacientes en el estado nororiental de Andhra Pradesh, India . Las observaciones se dividieron en dos categorías según si el paciente tenía enfermedad hepática. Además de nuestra variable objetivo, se proporcionan diez funciones, en su mayoría numéricas. Para describir estas características con más detalle, la siguiente tabla enumera las variables en el conjunto de datos.

Variable Descripción
edad Edad del paciente (todos los pacientes mayores de 89 años se etiquetan como 90
género Sexo del paciente (1 = femenino, 0 = masculino)
bilirrubina total Bilirrubina sérica total (en mg/dl)
bilirrubina directa Nivel de bilirrubina directa (en mg/dL)
fosfatasa alcalina Nivel sérico de fosfatasa alcalina (en U/L)
alanina_transaminasa Nivel sérico de alanina transaminasa (en U/L)
aspartato_transaminasa Nivel sérico de aspartato transaminasa (en U/L)
proteina total Proteína sérica total (en g/dL)
albúmina Nivel de albúmina sérica (en g/dL)
proporción_de_albúmina_globulina Proporción de albúmina a globulina
enfermo Variable objetivo (1 = enfermedad hepática, 0 = sin enfermedad hepática)

Obviamente, algunas medidas son parte de otras variables. Por ejemplo, la bilirrubina sérica total es la suma de los niveles de bilirrubina directa e indirecta, mientras que la cantidad de albúmina se utiliza para calcular el valor de la proteína sérica total y la relación albúmina-globulina. Por lo tanto, algunas características están altamente correlacionadas entre sí y se tratarán a continuación.

Preprocesamiento de datos

Distribución univariada

A continuación, investigue la distribución univariada de cada variable. Comience con la variable de destino y la única característica discreta: el género, que son ambas variables binarias.

##  所有离散变量的频率分布
plot_bar(ilpd,ggtheme = theme_bw())
05edafe3b62ce714b9ddb91d15ad3399.png
imagen-20220408132411502

Puede verse que la distribución de la variable objetivo (es decir, pacientes con y sin enfermedad hepática) está bastante desequilibrada, como se muestra en el histograma: el número de pacientes con y sin enfermedad hepática es 416 y 167, respectivamente. La subrepresentación de una clase puede empeorar el rendimiento de un modelo de ML. Para investigar esta pregunta, los autores también ajustan el modelo en un conjunto de datos en el que la clase minoritaria se sobremuestrea aleatoriamente, lo que da como resultado un conjunto de datos totalmente equilibrado. Además, aplicamos muestreo estratificado para asegurar que las proporciones de las clases se mantuvieran durante el proceso de validación cruzada. Las únicas características discretas gendertambién están bastante desequilibradas.

## 查看所有连续变量的频率分布直方图
plot_histogram(ilpd,ggtheme = theme_mlr3())
ac543108d06e15d3f5ff20d99c02efb2.png
histograma

Se puede ver que algunas características del indicador están extremadamente sesgadas hacia la derecha y contienen varios valores extremos. Para reducir el efecto de los valores atípicos, y dado que algunos modelos asumen la normalidad de las características, logtransformamos estas variables.

Agrupación de características

Para delinear 目标la 特征relación entre y, seguimos las 类别distribuciones analizadas特征 . Primero, observamos el género de características discretas.

plot_bar(ilpd,by = 'diseased',ggtheme = theme_mlr3())
feb0fe87de6cb6ba984ed40d481d6fd0.png

En la categoría de "enfermedad", hubo una proporción ligeramente mayor de hombres, pero en general, la diferencia no fue significativa. Sumado a esto, como mencionamos anteriormente, se puede observar un desequilibrio de género en ambas categorías .

Para ver la diferencia en las características continuas, comparamos lo siguiente boxplots, donde las características con sesgo a la derecha no se han transformado logarítmicamente.

## View bivariate continuous distribution based on `diseased`
plot_boxplot(ilpd,by = 'diseased')
bb60263b0214a26d3c5b2121edd024d5.png

Puede ver que excepto total_protein, para cada característica, obtenemos la diferencia entre las medianas de las dos clases. Vale la pena señalar que entre las características fuertemente sesgadas hacia la derecha, la clase "enfermedad" contiene valores mucho más extremos que la clase "sin enfermedad", probablemente debido a su mayor escala.

Como puede ver en el gráfico a continuación, este efecto se atenúa después de la transformación logarítmica. Además, estas características están más dispersas en la clase de "enfermedad", como lo indica la longitud de los diagramas de caja. En general, estas funciones parecen estar relacionadas con el objetivo, por lo que tiene sentido utilizarlas para esta tarea y modelar su relación con el objetivo.

transformación de registro de algunas características

ilpd_log = ilpd %>%
  mutate(
    # Log for features with skewed distributions
    alanine_transaminase = log(alanine_transaminase),
    total_bilirubin =log(total_bilirubin),
    alkaline_phosphatase = log(alkaline_phosphatase),
    aspartate_transaminase = log(aspartate_transaminase),
    direct_bilirubin = log(direct_bilirubin)
  )
plot_histogram(ilpd_log,ggtheme = theme_mlr3(),ncol = 3)
plot_boxplot(ilpd_log,by = 'diseased')
da8fff1dc4d62c73912ee433676d725d.png 07248c445afe2b67c350f63038e33ef2.png

Se puede ver que logla distribución de datos transformados ha mejorado mucho.

análisis relacionado

Como mencionamos en la descripción de los datos, algunas características se miden indirectamente por otra característica. Esto demuestra que están altamente correlacionados. Algunas de las suposiciones del modelo que queremos comparar son 独立características 多重共线性o tienen problemas. Por lo tanto, examinamos la correlación entre las características.

plot_correlation(ilpd)
b4a518d60c89308de46445a4ee77ad13.png

Como puede verse, cuatro de los pares tienen coeficientes de correlación muy altos. Mirando estas características, está claro que interactúan entre sí. Dado que la complejidad del modelo debe minimizarse y debido a consideraciones de multicolinealidad, decidimos tomar solo una de cada par de características. Al decidir qué características mantener, seleccionamos aquellas que eran más específicas y relevantes para la enfermedad hepática. Por lo tanto, elegimos albúmina, no la proporción de albúmina a globulina, ni la cantidad total de proteína. El mismo punto se aplica al uso de la cantidad de bilirrubina directa en lugar de la bilirrubina total. Con respecto a la aspartato aminotransferasa y la alanina aminotransferasa, no notamos ninguna diferencia fundamental en los datos de estas dos características, por lo que elegimos la aspartato aminotransferasa de manera arbitraria.

conjunto de datos final

## Reducing, transforming and scaling dataset
ilpd = ilpd %>%
  select(-total_bilirubin, -alanine_transaminase, -total_protein,
         -albumin_globulin_ratio) %>%
  mutate(
    # Recode gender
    gender = as.numeric(ifelse(gender == "Female", 1, 0)),
     # Remove labels for class
    diseased = factor(ifelse(diseased == "yes", 1, 0)),
     # Log for features with skewed distributions
    alkaline_phosphatase = log(alkaline_phosphatase),
    aspartate_transaminase = log(aspartate_transaminase),
    direct_bilirubin = log(direct_bilirubin)
  )
## 标准化
po_scale = po("scale")
po_scale$param_set$values$affect_columns =
  selector_name(c("age", "direct_bilirubin", "alkaline_phosphatase",
                  "aspartate_transaminase", "albumin"))
task_liver = as_task_classif(ilpd_m, target = "diseased", positive = "1")
ilpd_f = po_scale$train(list(task_liver))[[1]]$data()

Finalmente, realizamos todas las funciones de variables continuas 标准化, lo cual es especialmente importante para los modelos k-NN. La siguiente tabla muestra el conjunto de datos final y las transformaciones que aplicamos. Nota: A diferencia de las transformaciones logarítmicas o de otro tipo, la escala depende de los datos en sí. Escalar los datos antes de que se dividan puede provocar una fuga de datos (ver: Nature Reviews Genetics | Errores comunes de aplicar el aprendizaje automático en genómica ) porque se comparte la información del conjunto de prueba y entrenamiento. Dado que las fugas de datos pueden conducir a un mayor rendimiento, el escalado siempre debe aplicarse individualmente a cada división de datos causada por el flujo de trabajo de ML. Por lo tanto, recomendamos encarecidamente usarlo en este caso PipeOpScale.

Aprendices y Tuning

Primero, necesitamos definir uno task, que contenga el conjunto de datos final y algo de metainformación. Además, necesitamos especificar la clase positiva, porque el paquete tiene como valor predeterminado la primera clase positiva como clase positiva. La asignación de la clase positiva tiene implicaciones para las evaluaciones posteriores.

## Task definition
task_liver = as_task_classif(ilpd_f, target = "diseased", positive = "1")

A continuación evaluaremos los objetivos de clasificación binaria de logistic regression, linear discriminant analysis(LDA), quadratic discriminant analysis(QDA), naive Bayes( k-nearest neighbourk-NN), classification trees(CART) y .random forest

# detect overfitting
install.packages('e1071')
install.packages('kknn')
learners = list(
  learner_logreg = lrn("classif.log_reg", predict_type = "prob",
                       predict_sets = c("train", "test")),
  learner_lda = lrn("classif.lda", predict_type = "prob",
                    predict_sets = c("train", "test")),
  learner_qda = lrn("classif.qda", predict_type = "prob",
                    predict_sets = c("train", "test")),
  learner_nb = lrn("classif.naive_bayes", predict_type = "prob",
                   predict_sets = c("train", "test")),
  learner_knn = lrn("classif.kknn", scale = FALSE,
                    predict_type = "prob"),
  learner_rpart = lrn("classif.rpart",
                      predict_type = "prob"),
  learner_rf = lrn("classif.ranger", num.trees = 1000,
                   predict_type = "prob")
)

ajuste de parámetros

Para encontrar los mejores hiperparámetros, utilizamos la búsqueda aleatoria para cubrir mejor el espacio de hiperparámetros. Definimos los hiperparámetros a sintonizar. Solo ajustamos los hiperparámetros de k-NN, CARTy , ya que otros métodos tienen suposiciones sólidas y sirven como líneas de base.随机森林

Para k-NN, elegimos 3 como klímite inferior (número de vecinos) y 50 como límite superior. Un k demasiado pequeño puede dar lugar a un sobreajuste. También probamos diferentes medidas de distancia ( Manhattan distance1, Euclidean distance2) y núcleos. Para CART, ajustamos los hiperparámetros cp(parámetro de complejidad) y minsplit(para tratar de dividir, el número mínimo de observaciones en un nodo). cpTamaño controlado tree: los valores pequeños conducen a un ajuste excesivo, mientras que los valores grandes conducen a un ajuste insuficiente. También ajustamos los 随机森林的parámetros del tamaño mínimo de los nodos terminales y la cantidad de variables candidatas (desde 1 hasta la cantidad de características) muestreadas aleatoriamente en cada división.

tune_ps_knn = ps(
  k = p_int(lower = 3, upper = 50), # Number of neighbors considered
  distance = p_dbl(lower = 1, upper = 3),
  kernel = p_fct(levels = c("rectangular", "gaussian", "rank", "optimal"))
)
tune_ps_rpart = ps(
  # Minimum number of observations that must exist in a node in order for a
  # split to be attempted
  minsplit = p_int(lower = 10, upper = 40),
  cp = p_dbl(lower = 0.001, upper = 0.1) # Complexity parameter
)
tune_ps_rf = ps(
  # Minimum size of terminal nodes
  min.node.size = p_int(lower = 10, upper = 50),
  # Number of variables randomly sampled as candidates at each split
  mtry = p_int(lower = 1, upper = 6)
)

El siguiente paso es mlr3tuninginstanciar AutoTunerel . Adoptamos el bucle interno para el remuestreo anidado 5-fold交叉验证法. El número de veces de evaluación se fijó en 100 veces como criterio de parada. Usamos AUCcomo métrica de evaluación, .

Como se mencionó anteriormente, elegimos la clase perfectamente equilibrada debido a las clases objetivo desequilibradas. Al usar mlr3pipelines, podemos aplicar la función de referencia más tarde.

# Oversampling minority class to get perfectly balanced classes
po_over = po("classbalancing", id = "oversample", adjust = "minor",
             reference = "minor", shuffle = FALSE, ratio = 416/167)
table(po_over$train(list(task_liver))$output$truth()) # Check class balance

# Learners with balanced/oversampled data
learners_bal = lapply(learners, function(x) {
  GraphLearner$new(po_scale %>>% po_over %>>% x)
})
lapply(learners_bal, function(x) x$predict_sets = c("train", "test"))

Ajuste de modelos y evaluación comparativa

Después de definir al alumno, elegir el método interno para el remuestreo anidado y configurar el ajustador, comenzamos a elegir el método de remuestreo externo. Elegimos un método de validación cruzada quíntuple estratificado para preservar la distribución de la variable objetivo, libre de sobremuestreo. Sin embargo, resulta que la validación cruzada normal sin estratificación también produce resultados muy similares.

# 5-fold cross-validation
resampling_outer = rsmp(id = "cv", .key = "cv", folds = 5L)

# Stratification
task_liver$col_roles$stratum = task_liver$target_names

Para clasificar a los diferentes alumnos y, en última instancia, decidir cuál es el mejor para la tarea en cuestión, utilizamos la evaluación comparativa. El bloque de código a continuación ejecuta nuestro punto de referencia para todos los estudiantes.

design = benchmark_grid(
  tasks = task_liver,
  learners = c(learners, learners_bal),
  resamplings = resampling_outer
)

bmr = benchmark(design, store_models = FALSE) ## 耗时较长

Como se mencionó anteriormente, elegimos el método de validación cruzada estratificada de 5 veces . Esto significa que el rendimiento se determina como el promedio de cinco evaluaciones del modelo train-test-splital 80 % y al 20 %. Además, la elección de las métricas de rendimiento es crucial para clasificar a los diferentes alumnos. Si bien cada uno tiene su caso de uso específico, elegimos una AUCmétrica de rendimiento que tiene en cuenta tanto la sensibilidad como la especificidad, que también usamos para el ajuste de hiperparámetros.

Comenzamos AUCcomparando a todos los alumnos, con y sin sobremuestreo, y datos de entrenamiento y prueba.

measures = list(
  msr("classif.auc", predict_sets = "train", id = "auc_train"),
  msr("classif.auc", id = "auc_test")
)

tab = bmr2$aggregate(measures)
tab_1 = tab[,c('learner_id','auc_train','auc_test')]
print(tab_1)
> print(tab_1)
                               learner_id auc_train  auc_test
 1:                       classif.log_reg 0.7548382 0.7485372
 2:                           classif.lda 0.7546522 0.7487159
 3:                           classif.qda 0.7683438 0.7441634
 4:                   classif.naive_bayes 0.7539374 0.7498427
 5:                    classif.kknn.tuned 0.8652143 0.7150679
 6:                   classif.rpart.tuned 0.7988561 0.6847818
 7:                  classif.ranger.tuned 0.9871615 0.7426650
 8:      scale.oversample.classif.log_reg 0.7540066 0.7497002
 9:          scale.oversample.classif.lda 0.7537952 0.7489675
10:          scale.oversample.classif.qda 0.7679012 0.7481963
11:  scale.oversample.classif.naive_bayes 0.7536208 0.7503436
12:   scale.oversample.classif.kknn.tuned 0.9982251 0.6870297
13:  scale.oversample.classif.rpart.tuned 0.8903927 0.6231100
14: scale.oversample.classif.ranger.tuned 1.0000000 0.7409655

A partir de los resultados anteriores, se puede ver que la regresión logística, LDA, QDA y NB funcionan de manera muy similar en los datos de entrenamiento y prueba, independientemente de si se aplica sobremuestreo o no. Por otro lado, k-NN, CART y Random Forest predicen mucho mejor los datos de entrenamiento, lo que sugiere un sobreajuste.

Además, el sobremuestreo AUCcasi no deja cambios en el rendimiento de todos los alumnos.

Los diagramas de caja a continuación muestran el AUCrendimiento de la validación cruzada de 5 veces para todos los alumnos.

# boxplot of AUC values across the 5 folds
autoplot(bmr2, measure = msr("classif.auc"))
699e3f65d78751a95ee30a7c11048b7d.png
imagen-20220408223435031
autoplot(bmr2,type = "roc")+
  scale_color_discrete() +
  theme_bw()
c736af0aebc8edc2c4a4bcb51471da7d.png
imagen-20220410085535155

Posteriormente, se emiten la sensibilidad, la especificidad, la tasa de falsos negativos (FNR) y la tasa de falsos positivos (FPR) de cada alumno.

tab2 = bmr2$aggregate(msrs(c('classif.auc', 'classif.sensitivity','classif.specificity',
                            'classif.fnr', 'classif.fpr')))
tab2 = tab2[,c('learner_id','classif.auc','classif.sensitivity','classif.specificity',
               'classif.fnr', 'classif.fpr')]
print(tab2)
> print(tab2)
                               learner_id classif.auc classif.sensitivity
 1:                       classif.log_reg   0.7485372           0.8917097
 2:                           classif.lda   0.7487159           0.9037005
 3:                           classif.qda   0.7441634           0.6779116
 4:                   classif.naive_bayes   0.7498427           0.6250430
 5:                    classif.kknn.tuned   0.7180074           0.8509180
 6:                   classif.rpart.tuned   0.6987046           0.8679289
 7:                  classif.ranger.tuned   0.7506405           0.9447504
 8:      scale.oversample.classif.log_reg   0.7475678           0.6008893
 9:          scale.oversample.classif.lda   0.7489090           0.5841652
10:          scale.oversample.classif.qda   0.7431096           0.5529547
11:  scale.oversample.classif.naive_bayes   0.7494055           0.5505164
12:   scale.oversample.classif.kknn.tuned   0.6924480           0.6948078
13:  scale.oversample.classif.rpart.tuned   0.6753005           0.7090075
14: scale.oversample.classif.ranger.tuned   0.7393948           0.7427424
    classif.specificity classif.fnr classif.fpr
 1:           0.2516934  0.10829030   0.7483066
 2:           0.1855615  0.09629948   0.8144385
 3:           0.6946524  0.32208835   0.3053476
 4:           0.7488414  0.37495697   0.2511586
 5:           0.2581105  0.14908204   0.7418895
 6:           0.3108734  0.13207114   0.6891266
 7:           0.1554367  0.05524957   0.8445633
 8:           0.7663102  0.39911073   0.2336898
 9:           0.8023173  0.41583477   0.1976827
10:           0.8139037  0.44704532   0.1860963
11:           0.8381462  0.44948365   0.1618538
12:           0.5811052  0.30519220   0.4188948
13:           0.5449198  0.29099254   0.4550802
14:           0.5509804  0.25725760   0.4490196

Resulta que sin sobremuestreo, la regresión logística, LDA, k-NN, CART y los bosques aleatorios tienen una sensibilidad alta y una especificidad bastante baja; por otro lado, QDA y el ingenuo Bayes Sterling obtuvieron una puntuación relativamente alta en especificidad, pero no tanto. alto en sensibilidad. Por definición, la alta sensibilidad (especificidad) se deriva de una baja tasa de falsos negativos (positivos), que también se refleja en los datos.

Extraer un solo modelo

## 提取随机森林模型
bmr_rf = bmr2$clone(deep = TRUE)$filter(learner_ids = 'classif.ranger.tuned')
## ROC
autoplot(bmr_rf,type = "roc")+
  scale_color_discrete() +
  theme_bw()
## PRC
autoplot(bmr_rf, type = "prc")+
  scale_color_discrete() +
  theme_bw()
68d0c638ad3d45553328355bfc59fcf4.png
República de China
48a30fd00d7e95603d52c6bfa5b2d5d1.png
República Popular China

En cuanto a qué alumno funciona mejor, incluso si se debe usar el sobremuestreo, mucho depende de las implicaciones prácticas de la sensibilidad y la especificidad. En términos de importancia práctica, uno de los dos puede superar al otro muchas veces. Considere el ejemplo de la típica prueba de diagnóstico rápido del VIH, donde la alta sensibilidad a expensas de la baja especificidad puede causar un shock (innecesario) pero por lo demás no es peligroso, mientras que la baja sensibilidad es muy peligrosa. Como suele ser el caso, no hay un "mejor modelo" en blanco y negro. En resumen, incluso con sobremuestreo, ninguno de nuestros modelos funcionó bien en términos de sensibilidad y especificidad. En nuestro caso, debemos pensar: cuáles son las consecuencias de una alta especificidad a costa de una baja sensibilidad, lo que significa decirles a muchos pacientes con enfermedad hepática que están sanos; y cuáles son las consecuencias de una alta sensibilidad a costa de una baja especificidad , lo que significa decirle a muchos pacientes sanos que tienen una enfermedad hepática. En ausencia de más información específica del tema, solo podemos indicar el alumno que se desempeña mejor en la métrica de rendimiento específica elegida. Como se mencionó anteriormente, Random Forest basado AUCen Random Forest funciona mejor. Además, Random Forest es el alumno con la puntuación de sensibilidad más alta (la más baja) FNR, mientras que Naive Bayes es FPRel alumno con la puntuación de especificidad mejor (la más baja).

Sin embargo, nuestro análisis no es en modo alguno exhaustivo. En el nivel de funciones, si bien nos hemos centrado casi exclusivamente en los aspectos de aprendizaje automático y análisis estadístico de nuestro análisis, también es posible profundizar en el tema real (enfermedad hepática) e intentar comprender más las variables y las correlaciones e interacciones subyacentes. profundamente sexo. Esto también puede significar que las variables que se han eliminado se vuelven a considerar. Además, la ingeniería de características y el preprocesamiento de datos se pueden realizar en el conjunto de datos, como el uso del análisis de componentes principales. Con respecto al ajuste de hiperparámetros, considere usar un espacio de hiperparámetros más grande y evaluar la cantidad de hiperparámetros diferentes. Además, los ajustes también se pueden aplicar a algunos alumnos que etiquetamos como alumnos de referencia. Finalmente, existen muchos más clasificadores, especialmente máquinas de vectores de soporte y aumento de gradiente que se pueden aplicar adicionalmente a esta tarea y potencialmente producir mejores resultados.

referencia

  • (mlr3gallery: clasificación de pacientes con hígado según medidas de diagnóstico) (https://mlr3gallery.mlr-org.com/posts/2020-09-11-liver-patient-classification/)


5aae88bbdbb1540c97957154dcd2c8c7.png

Supongo que te gusta

Origin blog.csdn.net/weixin_45822007/article/details/124114043
Recomendado
Clasificación