El archivo heart.csv se ha descargado previamente y se
encuentra almacenado en el directorio data/ de esta
práctica.
Antes de crear el data frame HeartStat, comprobamos que el
archivo heart.csv está disponible en el directorio
indicado. Si existe, se importa mediante la función
read.csv(). En caso contrario, se detiene la ejecución y se
muestra un mensaje de error.
Una vez importados los datos, se muestran las seis primeras y las
seis últimas observaciones mediante las funciones head() y
tail(), respectivamente.
# Definir la ruta del archivo CSV
file_path <- file.path("data", "heart.csv")
# Verificar que el archivo existe antes de importarlo
if (!file.exists(file_path)) {
stop("No se encuentra el archivo heart.csv en el directorio data/")
}
# Importar los datos en un data frame llamado HeartStat
HeartStat <- read.csv(file_path)
# Verificar que el objeto creado es un data frame
stopifnot(is.data.frame(HeartStat))
# Mostrar las seis primeras observaciones
head(HeartStat)
## age sex cp trestbps chol fbs restecg thalach exang oldpeak slope ca thal
## 1 70 1 3 130 322 0 2 109 0 2.4 1 3 1
## 2 67 0 2 115 564 0 2 160 0 1.6 1 0 3
## 3 57 1 1 124 261 0 0 141 0 0.3 0 0 3
## 4 64 1 3 128 263 0 0 105 1 0.2 1 1 3
## 5 74 0 1 120 269 0 2 121 1 0.2 0 1 1
## 6 65 1 3 120 177 0 0 140 0 0.4 0 0 3
## target
## 1 1
## 2 0
## 3 1
## 4 0
## 5 0
## 6 0
# Mostrar las seis últimas observaciones
tail(HeartStat)
## age sex cp trestbps chol fbs restecg thalach exang oldpeak slope ca thal
## 265 48 1 1 110 229 0 0 168 0 1.0 2 0 3
## 266 52 1 2 172 199 1 0 162 0 0.5 0 0 3
## 267 44 1 1 120 263 0 0 173 0 0.0 0 0 3
## 268 56 0 1 140 294 0 2 153 0 1.3 1 0 1
## 269 57 1 3 140 192 0 0 148 0 0.4 1 0 2
## 270 67 1 3 160 286 0 2 108 1 1.5 1 3 1
## target
## 265 1
## 266 0
## 267 0
## 268 0
## 269 0
## 270 1
Iniciamos el analisis del dataframe:
# Nombres de las variables
names(HeartStat)
## [1] "age" "sex" "cp" "trestbps" "chol" "fbs"
## [7] "restecg" "thalach" "exang" "oldpeak" "slope" "ca"
## [13] "thal" "target"
# Estructura y tipos de datos
str(HeartStat)
## 'data.frame': 270 obs. of 14 variables:
## $ age : int 70 67 57 64 74 65 56 59 60 63 ...
## $ sex : int 1 0 1 1 0 1 1 1 1 0 ...
## $ cp : int 3 2 1 3 1 3 2 3 3 3 ...
## $ trestbps: int 130 115 124 128 120 120 130 110 140 150 ...
## $ chol : int 322 564 261 263 269 177 256 239 293 407 ...
## $ fbs : int 0 0 0 0 0 0 1 0 0 0 ...
## $ restecg : int 2 2 0 0 2 0 2 2 2 2 ...
## $ thalach : int 109 160 141 105 121 140 142 142 170 154 ...
## $ exang : int 0 0 0 1 1 0 1 1 0 0 ...
## $ oldpeak : num 2.4 1.6 0.3 0.2 0.2 0.4 0.6 1.2 1.2 4 ...
## $ slope : int 1 1 0 1 0 0 1 1 1 1 ...
## $ ca : int 3 0 0 1 1 0 1 1 2 3 ...
## $ thal : int 1 3 3 3 1 3 2 3 3 3 ...
## $ target : int 1 0 1 0 0 0 1 1 1 1 ...
# Dimensiones: número de filas y columnas
dim(HeartStat)
## [1] 270 14
# Tamaño de la muestra
nrow(HeartStat)
## [1] 270
# Número de variables
ncol(HeartStat)
## [1] 14
# Valores ausentes por variable
colSums(is.na(HeartStat))
## age sex cp trestbps chol fbs restecg thalach
## 0 0 0 0 0 0 0 0
## exang oldpeak slope ca thal target
## 0 0 0 0 0 0
# Número total de valores ausentes
sum(is.na(HeartStat))
## [1] 0
En base a las caracteristicas del dataset proponemos el siguiente analisis exploratorio:
| Naturaleza estadística | Variables | Análisis exploratorio | Gráficos propuestos |
|---|---|---|---|
| Cuantitativas | age, trestbps,
chol, thalach, oldpeak |
Media, mediana, desviación estándar, cuartiles y valores atípicos | Histogramas y diagramas de cajas |
| Cuantitativa discreta | ca |
Frecuencias, media y distribución del número de vasos | Diagrama de barras |
| Categóricas binarias | sex, fbs, exang,
target |
Frecuencias absolutas y proporciones | Diagramas de barras |
| Categóricas con varios niveles | cp, restecg,
slope, thal |
Frecuencias y proporciones por categoría | Diagramas de barras |
| Cuantitativa y categórica | thalach y target |
Comparación de distribuciones entre grupos | Diagramas de cajas |
| Dos categóricas | cp y target |
Tablas de contingencia y proporciones condicionadas | Barras agrupadas o mapas de calor |
| Dos cuantitativas | age y thalach |
Correlación y análisis de asociación | Diagramas de dispersión |
Para las variables cuantitativas podriamos estudiar la tendencia central, la dispersión y la forma de sus distribuciones. Mientras que para las variables categóricas, seria interesante hacer un analisis de frecuencias y proporciones.
Adicionalmente, los análisis bivariantes nos permitirian explorar posibles asociaciones entre las características clínicas de los pacientes y la presencia o ausencia de enfermedad cardíaca.
Antes de eliminar observaciones con valores ausentes, es importante considerar el mecanismo que ha generado dichos valores.
En estadística se distinguen tres mecanismos:
| Mecanismo | Significado | Ejemplo clínico |
|---|---|---|
MCAR |
Ausencia completamente aleatoria | Se pierde una analítica por un fallo técnico independiente del paciente. |
MAR |
Ausencia explicable por variables observadas | Los pacientes de mayor edad tienen menos registros completos. |
MNAR |
Ausencia relacionada con información no observada, incluso tras considerar las variables disponibles | Los pacientes con resultados especialmente desfavorables tienen mayor probabilidad de no disponer de esa medición. |
Si los valores ausentes siguen un mecanismo MCAR, eliminar los registros incompletos generalmente no introduce sesgo de selección, aunque reduce el tamaño muestral y la precisión estadística.
En cambio, bajo MAR, la ausencia depende de variables observadas. Si, por ejemplo, los pacientes de mayor edad presentan más expedientes incompletos, su exclusión podría alterar la composición de la muestra y sesgar las estimaciones de enfermedad cardíaca.
En el caso MNAR, la ausencia depende de información no observada, incluso después de considerar las variables disponibles, lo que puede producir estimaciones sistemáticamente sesgadas.
Por lo tanto, antes de aplicar un complete-case analysis, es imprescindible investigar los patrones de los valores ausentes
Para conocer su estructura, examinamos las dimensiones, los tipos de variables, los primeros registros y un resumen descriptivo del conjunto de datos.
# Cargar el conjunto de datos incluido en R
data("esoph", package = "datasets")
# Dimensiones del conjunto de datos
dim(esoph)
## [1] 88 5
# Estructura y tipos de variables
str(esoph)
## 'data.frame': 88 obs. of 5 variables:
## $ agegp : Ord.factor w/ 6 levels "25-34"<"35-44"<..: 1 1 1 1 1 1 1 1 1 1 ...
## $ alcgp : Ord.factor w/ 4 levels "0-39g/day"<"40-79"<..: 1 1 1 1 2 2 2 2 3 3 ...
## $ tobgp : Ord.factor w/ 4 levels "0-9g/day"<"10-19"<..: 1 2 3 4 1 2 3 4 1 2 ...
## $ ncases : num 0 0 0 0 0 0 0 0 0 0 ...
## $ ncontrols: num 40 10 6 5 27 7 4 7 2 1 ...
# Primeras seis observaciones
head(esoph)
## agegp alcgp tobgp ncases ncontrols
## 1 25-34 0-39g/day 0-9g/day 0 40
## 2 25-34 0-39g/day 10-19 0 10
## 3 25-34 0-39g/day 20-29 0 6
## 4 25-34 0-39g/day 30+ 0 5
## 5 25-34 40-79 0-9g/day 0 27
## 6 25-34 40-79 10-19 0 7
# Resumen descriptivo
summary(esoph)
## agegp alcgp tobgp ncases ncontrols
## 25-34:15 0-39g/day:23 0-9g/day:24 Min. : 0.000 Min. : 0.000
## 35-44:15 40-79 :23 10-19 :24 1st Qu.: 0.000 1st Qu.: 1.000
## 45-54:16 80-119 :21 20-29 :20 Median : 1.000 Median : 4.000
## 55-64:16 120+ :21 30+ :20 Mean : 2.273 Mean : 8.807
## 65-74:15 3rd Qu.: 4.000 3rd Qu.:10.000
## 75+ :11 Max. :17.000 Max. :60.000
# Categorías de consumo de alcohol y tabaco
levels(esoph$alcgp)
## [1] "0-39g/day" "40-79" "80-119" "120+"
levels(esoph$tobgp)
## [1] "0-9g/day" "10-19" "20-29" "30+"
| Variable | Nombre completo | ¿Qué representa? | Tipo estadístico |
|---|---|---|---|
agegp |
Age group | Grupo de edad de los participantes | Categórica ordinal |
alcgp |
Alcohol consumption group | Nivel de consumo de alcohol, en gramos por día | Categórica ordinal |
tobgp |
Tobacco consumption group | Nivel de consumo de tabaco, en gramos por día | Categórica ordinal |
ncases |
Number of cases | Número de personas con cáncer de esófago | Cuantitativa discreta |
ncontrols |
Number of controls | Número de personas del grupo de control, sin cáncer de esófago | Cuantitativa discreta |
Como acción de preparación de los datos, proponemos crear una nueva variable categórica denominada alc_group, agrupando los cuatro niveles originales de consumo de alcohol en dos categorías: menor consumo (menos de 80 g/día) y mayor consumo (80 g/día o más).
Esta transformación permite, por ejemplo, simplificar las comparaciones entre grupos y facilita la elaboración de tablas de contingencia y gráficos de barras que, a su vez, nos puede servir para explorar la asociación entre el consumo de alcohol y los casos de cáncer de esófago.
Hay que tener en cuenta que esta agregación, también implica una pérdida de información, ya que estamos elimimando las diferencias entre niveles originales. Además, el umbral de 80 g/día lo hemos elegido de forma arbitraria, aprovechando las categorías existentes en el dataset, pero sin aplicar ningún criterio estadístico ni justificarlo mediante evidencia clínica.
# Creamos una copia para preservar los datos originales
esoph_modificat <- esoph
# Agrupamos los niveles de consumo de alcohol
esoph_modificat$alc_group <- factor(
ifelse(
esoph_modificat$alcgp %in% c("0-39g/day", "40-79"),
"Menor consumo",
"Mayor consumo"
),
levels = c("Menor consumo", "Mayor consumo")
)
# Comprobamos la nueva clasificación
table(esoph_modificat$alcgp, esoph_modificat$alc_group)
##
## Menor consumo Mayor consumo
## 0-39g/day 23 0
## 40-79 23 0
## 80-119 0 21
## 120+ 0 21
# Revisamos la estructura del dataset modificado
str(esoph_modificat)
## 'data.frame': 88 obs. of 6 variables:
## $ agegp : Ord.factor w/ 6 levels "25-34"<"35-44"<..: 1 1 1 1 1 1 1 1 1 1 ...
## $ alcgp : Ord.factor w/ 4 levels "0-39g/day"<"40-79"<..: 1 1 1 1 2 2 2 2 3 3 ...
## $ tobgp : Ord.factor w/ 4 levels "0-9g/day"<"10-19"<..: 1 2 3 4 1 2 3 4 1 2 ...
## $ ncases : num 0 0 0 0 0 0 0 0 0 0 ...
## $ ncontrols: num 40 10 6 5 27 7 4 7 2 1 ...
## $ alc_group: Factor w/ 2 levels "Menor consumo",..: 1 1 1 1 1 1 1 1 2 2 ...
El conjunto de datos esoph contiene información de un
estudio de casos y controles sobre cáncer de esófago en Francia. Sus 88
filas representan estratos definidos por la combinación de grupo de
edad, consumo de alcohol y consumo de tabaco; por tanto, no corresponden
a 88 personas.
En cada estrato, ncases indica el número de casos de
cáncer de esófago y ncontrols el número de controles. Para
obtener el número de participantes de un grupo, debemos sumar estos
recuentos.
La tabla que comprueba la nueva variable alc_group
cuenta cuántos estratos originales se asignan a cada categoría de
consumo. Sirve para verificar la transformación, pero no muestra el
número de personas de cada grupo.
Exportamos el data frame esoph_modificat al directorio
data/ que previamente habiamos creado, le llamamos:
esoph_modificat.csv. Antes de exportarlo, comprobamos que
el directorio existe y, en caso contrario, lo creamos.
El argumento row.names = FALSE permite guardar los datos
sin incluir el índice de las filas.
# Definir el directorio de destino
data_dir <- "data"
# Comprobar si existe y crearlo en caso contrario
if (!dir.exists(data_dir)) {
dir.create(data_dir, recursive = TRUE)
}
# Definir la ruta del archivo
output_path <- file.path(data_dir, "esoph_modificat.csv")
# Exportar el data frame sin incluir índices de filas
write.csv(
esoph_modificat,
file = output_path,
row.names = FALSE
)
# Verificar que el archivo se ha creado
if (file.exists(output_path)) {
cat("Archivo CSV creado correctamente.\n")
cat("Ruta:", normalizePath(output_path), "\n")
} else {
stop("Error: no se ha podido crear el archivo CSV.")
}
## Archivo CSV creado correctamente.
## Ruta: /Users/pedro/Work/bioinformatics-r/PEC1/data/esoph_modificat.csv
Es verdad que los datos sobre hábitos de consumo de alcohol y tabaco pueden ayudar a identificar riesgos y mejorar la prevención de enfermedades.
También es cierto que existe un marco regulatorio, concretamente el Reglamento General de Protección de Datos (RGPD), que considera los datos de salud como información especialmente protegida.
Esto implica que su tratamiento requiere no solo una base jurídica válida, sino también una finalidad legítima y medidas adecuadas de confidencialidad y seguridad.
Sin embargo, el cumplimiento legal no garantiza por sí solo un uso ético de los datos.
De ahí que, si queremos hablar de ética en el uso de algoritmos, quizás deberíamos proponer que sigan los principios bioéticos de beneficencia, no maleficencia, autonomía y justicia. Esto es: no sería éticamente aceptable buscar una mejora general de la prevención sanitaria a costa de discriminar o perjudicar injustificadamente a determinados colectivos.
Considerando el data frame HeartStat, resuelva las siguientes cuestiones (incluya código y resultados):
Tenemos dos variables: - cp: tipo de dolor torácico. Es una variable categórica con cuatro modalidades. - target: diagnóstico de enfermedad cardíaca. Es una variable binaria: 0 (ausencia) y 1 (presencia).
# Mostrar las primeras observaciones de las dos variables
head(HeartStat[, c("cp", "target")])
## cp target
## 1 3 1
## 2 2 0
## 3 1 1
## 4 3 0
## 5 1 0
## 6 3 0
# Comprobar los valores que toma cada variable
unique(HeartStat$cp)
## [1] 3 2 1 0
unique(HeartStat$target)
## [1] 1 0
# Frecuencias absolutas de cada variable
table(HeartStat$cp)
##
## 0 1 2 3
## 20 42 79 129
table(HeartStat$target)
##
## 0 1
## 150 120
# Construir la tabla de frecuencias absolutas
tabla_cp_target <- table(
cp = HeartStat$cp,
target = HeartStat$target
)
# Mostrar la tabla
tabla_cp_target
## target
## cp 0 1
## 0 15 5
## 1 35 7
## 2 62 17
## 3 38 91
# Calcular proporciones por grupo de diagnóstico
prop_cp_target <- prop.table(tabla_cp_target, margin = 2)
# Mostrar los porcentajes
round(prop_cp_target * 100, 2)
## target
## cp 0 1
## 0 10.00 4.17
## 1 23.33 5.83
## 2 41.33 14.17
## 3 25.33 75.83
# Comprobar que cada columna suma 100 %
colSums(prop_cp_target) * 100
## 0 1
## 100 100
En base a lo que muestran los datos, podemos destacar el siguiente patron:
Tenemos dos variables:
sex: sexo del paciente. Es una variable categórica
binaria: 0 (mujer) y 1 (hombre).target: diagnóstico de enfermedad cardíaca. Es una
variable categórica binaria: 0 (ausencia) y 1 (presencia).Analizaremos la distribución del diagnóstico cardíaco según el sexo de los pacientes.
# Mostrar las primeras observaciones
head(HeartStat[, c("sex", "target")])
## sex target
## 1 1 1
## 2 0 0
## 3 1 1
## 4 1 0
## 5 0 0
## 6 1 0
# Comprobar los valores de ambas variables
unique(HeartStat$sex)
## [1] 1 0
unique(HeartStat$target)
## [1] 1 0
# Mostrar las frecuencias absolutas
table(HeartStat$sex)
##
## 0 1
## 87 183
table(HeartStat$target)
##
## 0 1
## 150 120
# Construir la tabla de frecuencias absolutas
tabla_sex_target <- table(
sex = HeartStat$sex,
target = HeartStat$target
)
# Mostrar la tabla
tabla_sex_target
## target
## sex 0 1
## 0 67 20
## 1 83 100
# Calcular las proporciones dentro de cada sexo
prop_sex_target <- prop.table(tabla_sex_target, margin = 1)
# Mostrar las proporciones en porcentaje
round(prop_sex_target * 100, 2)
## target
## sex 0 1
## 0 77.01 22.99
## 1 45.36 54.64
# Comprobar que cada fila suma 100 %
rowSums(prop_sex_target) * 100
## 0 1
## 100 100
# Preparar los porcentajes para el gráfico
porcentajes <- t(prop_sex_target) * 100
# Gráfico de barras agrupadas
posiciones <- barplot(
porcentajes,
beside = TRUE,
col = c("#7EBEA3", "#3498DB"),
names.arg = c("Mujeres", "Hombres"),
legend.text = c("Sin enfermedad", "Con enfermedad"),
args.legend = list(x = "topright", bty = "n"),
main = "Presencia de enfermedad cardíaca según el sexo",
xlab = "Sexo",
ylab = "Porcentaje de pacientes (%)",
ylim = c(0, 100)
)
# Mostrar los porcentajes encima de cada barra
text(
x = posiciones,
y = porcentajes + 3,
labels = paste0(sprintf("%.1f", porcentajes), "%"),
cex = 0.9
)
Los datos sugieren una asociación decriptiva ente sexo y diagnostico:
54,64 % de los hombres presenta enfermedad cardíaca, frente al 22,99 %
de las mujeres
Tenemos dos variables:
fbs: nivel de azúcar en sangre en ayunas. Es una
variable categórica binaria: 0 (glucosa ≤ 120 mg/dL) y 1 (glucosa >
120 mg/dL).target: diagnóstico de enfermedad cardíaca. Es una
variable categórica binaria: 0 (ausencia) y 1 (presencia).Analizaremos la presencia de enfermedad cardíaca según las categorías
de fbs, utilizando frecuencias absolutas.
# Mostrar las primeras observaciones
head(HeartStat[, c("fbs", "target")])
## fbs target
## 1 0 1
## 2 0 0
## 3 0 1
## 4 0 0
## 5 0 0
## 6 0 0
# Comprobar los valores de ambas variables
unique(HeartStat$fbs)
## [1] 0 1
unique(HeartStat$target)
## [1] 1 0
# Mostrar las frecuencias absolutas
table(HeartStat$fbs)
##
## 0 1
## 230 40
table(HeartStat$target)
##
## 0 1
## 150 120
# Construir la tabla de frecuencias absolutas
tabla_fbs_target <- table(
fbs = HeartStat$fbs,
target = HeartStat$target
)
# Mostrar la tabla
tabla_fbs_target
## target
## fbs 0 1
## 0 127 103
## 1 23 17
# Comprobar el número total de pacientes
sum(tabla_fbs_target)
## [1] 270
La tabla de contingencia muestra que, entre los pacientes con niveles
de glucosa en ayunas iguales o inferiores a 120 mg/dL
(fbs = 0), 103 presentan enfermedad cardíaca y 127 no
presentan la enfermedad.
Entre los pacientes con niveles superiores a 120 mg/dL
(fbs = 1), 17 presentan enfermedad cardíaca y 23 no la
presentan.
En términos absolutos, encontramos más pacientes con enfermedad
cardíaca en la categoría fbs = 0. Sin embargo, esta
categoría también contiene un número considerablemente mayor de
pacientes (230 frente a 40).
Si consideramos las proporciones dentro de cada categoría, observamos
que el 44,78 % de los pacientes con fbs = 0 presenta
enfermedad cardíaca, frente al 42,50 % de los pacientes con
fbs = 1.
Por tanto, en esta muestra no observamos grandes diferencias descriptivas en la proporción de enfermedad cardíaca entre las dos categorías de glucosa en ayunas.
En este ejercicio utilizamos cuatro variables:
target: diagnóstico de enfermedad cardíaca, donde 1
indica presencia de la enfermedad.sex: sexo del paciente, donde 1 corresponde a
hombre.trestbps: presión arterial en reposo, variable
cuantitativa.exang: presencia de angina inducida por el ejercicio,
donde 0 indica ausencia y 1 presencia.Seleccionaremos los pacientes con diagnóstico positivo, de sexo
masculino y con una presión arterial en reposo superior a la media de
toda la muestra. Posteriormente, contaremos los pacientes según la
categoría de exang.
# Calcular la presión arterial media de toda la muestra
media_trestbps <- mean(HeartStat$trestbps, na.rm = TRUE)
# Mostrar la media
media_trestbps
## [1] 131.3444
# Explorar las categorías de angina de esfuerzo
table(HeartStat$exang)
##
## 0 1
## 181 89
Una vez calculada la media, utilizaremos subset() para seleccionar los pacientes que cumplen las tres condiciones y table() para contar los casos por categoría de exang.
# Seleccionar los pacientes que cumplen las condiciones
pacientes_filtrados <- subset(
HeartStat,
target == 1 &
sex == 1 &
trestbps > media_trestbps
)
# Contar pacientes por categoría de angina de esfuerzo
tabla_exang <- table(
exang = factor(pacientes_filtrados$exang, levels = c(0, 1))
)
# Mostrar los resultados
tabla_exang
## exang
## 0 1
## 18 24
# Comprobar el total de pacientes seleccionados
nrow(pacientes_filtrados)
## [1] 42
# Presentar los recuentos con etiquetas descriptivas
tabla_resultado <- data.frame(
Angina = c("Sin angina", "Con angina"),
Pacientes = as.integer(tabla_exang)
)
knitr::kable(
tabla_resultado,
caption = "Pacientes con diagnóstico positivo, hombres y presión arterial superior a la media"
)
| Angina | Pacientes |
|---|---|
| Sin angina | 18 |
| Con angina | 24 |
La presión arterial media en reposo de la muestra es de 131,34 mmHg.
Al seleccionar los pacientes hombres con diagnóstico positivo de enfermedad cardíaca y una presión arterial en reposo superior a la media, obtenemos un total de 42 pacientes.
De estos, 18 no presentan angina inducida por el ejercicio
(exang = 0), mientras que 24 sí la presentan
(exang = 1). Por lo tanto, dentro del grupo seleccionado,
hay más pacientes con angina de esfuerzo que sin ella.
Existe una expresión, «Garbage in, garbage out» (GIGO), que hace referencia a que, si los datos de entrada son deficientes o están sesgados, los resultados del modelo también pueden verse afectados.
Esto no solo se refiere a la calidad de los datos, sino también a la representatividad de las distintas categorías de la muestra. En términos prácticos, antes de implementar un algoritmo, debemos comprobar la calidad de los datos y si la muestra representa adecuadamente a la población del hospital, incluyendo los distintos grupos de sexo, edad y etnia.
Después, es necesario evaluar el rendimiento por subgrupos mediante la sensibilidad, la especificidad y las tasas de falsos negativos y falsos positivos. Esto permite identificar si el algoritmo omite más casos de enfermedad o genera más falsas alarmas en determinados colectivos. Además, debemos considerar el tamaño de cada subgrupo y la incertidumbre de las estimaciones.
También debemos comprobar la calibración del modelo por subgrupos, es decir, si las probabilidades de enfermedad que predice se corresponden con las frecuencias observadas.
Una prueba chi-cuadrado entre sexo y diagnóstico puede detectar una asociación en los datos, pero no permite determinar por sí sola si el algoritmo presenta sesgos en sus predicciones.
Finalmente, debemos realizar una validación externa con pacientes de otros centros y una evaluación en el hospital donde se utilizará. El algoritmo debe contar con supervisión clínica y, tras su implantación, requiere seguimiento para detectar cambios en su rendimiento y posibles desigualdades entre grupos.
El objetivo de este apartado es trabajar los conceptos de estadística descriptiva y de representación de gráficos.
Antes de construir la tabla estadística, comprobamos los datos y sus posibles valores ausentes.
# Seleccionar las variables cuantitativas
variables_cuantitativas <- HeartStat[, c(
"age", "trestbps", "chol", "thalach"
)]
# Mostrar las primeras observaciones
head(variables_cuantitativas)
## age trestbps chol thalach
## 1 70 130 322 109
## 2 67 115 564 160
## 3 57 124 261 141
## 4 64 128 263 105
## 5 74 120 269 121
## 6 65 120 177 140
# Obtener un resumen inicial
summary(variables_cuantitativas)
## age trestbps chol thalach
## Min. :29.00 Min. : 94.0 Min. :126.0 Min. : 71.0
## 1st Qu.:48.00 1st Qu.:120.0 1st Qu.:213.0 1st Qu.:133.0
## Median :55.00 Median :130.0 Median :245.0 Median :153.5
## Mean :54.43 Mean :131.3 Mean :249.7 Mean :149.7
## 3rd Qu.:61.00 3rd Qu.:140.0 3rd Qu.:280.0 3rd Qu.:166.0
## Max. :77.00 Max. :200.0 Max. :564.0 Max. :202.0
# Comprobar si existen valores ausentes
colSums(is.na(variables_cuantitativas))
## age trestbps chol thalach
## 0 0 0 0
Una vez comprobados los datos, calculamos los estadísticos solicitados:
# Calcular las estadísticas descriptivas de cada variable
tabla_descriptiva <- data.frame(
Variable = names(variables_cuantitativas),
Media = sapply(variables_cuantitativas, mean, na.rm = TRUE),
Desv_Estandar = sapply(variables_cuantitativas, sd, na.rm = TRUE),
Mediana = sapply(variables_cuantitativas, median, na.rm = TRUE),
Q1 = sapply(variables_cuantitativas, quantile,
probs = 0.25, na.rm = TRUE),
Q3 = sapply(variables_cuantitativas, quantile,
probs = 0.75, na.rm = TRUE),
IQR = sapply(variables_cuantitativas, IQR, na.rm = TRUE),
row.names = NULL
)
# Mostrar la tabla con dos decimales
knitr::kable(
tabla_descriptiva,
digits = 2,
caption = "Estadísticas descriptivas de las variables cuantitativas"
)
| Variable | Media | Desv_Estandar | Mediana | Q1 | Q3 | IQR |
|---|---|---|---|---|---|---|
| age | 54.43 | 9.11 | 55.0 | 48 | 61 | 13 |
| trestbps | 131.34 | 17.86 | 130.0 | 120 | 140 | 20 |
| chol | 249.66 | 51.69 | 245.0 | 213 | 280 | 67 |
| thalach | 149.68 | 23.17 | 153.5 | 133 | 166 | 33 |
Podemos identificar algunos patrones:
Para identificar los posibles valores atípicos usamos la regla de Tukey Q1 − 1,5 × IQR y Q3 + 1,5 × IQR.
# Calcular los límites de Tukey y contar los valores atípicos
tabla_atipicos <- data.frame(
Variable = names(variables_cuantitativas),
Limite_inferior = NA_real_,
Limite_superior = NA_real_,
N_atipicos = NA_integer_
)
for (i in seq_along(variables_cuantitativas)) {
x <- na.omit(variables_cuantitativas[[i]])
q1 <- quantile(x, 0.25)
q3 <- quantile(x, 0.75)
iqr <- IQR(x)
li <- q1 - 1.5 * iqr
ls <- q3 + 1.5 * iqr
tabla_atipicos$Limite_inferior[i] <- li
tabla_atipicos$Limite_superior[i] <- ls
tabla_atipicos$N_atipicos[i] <- sum(x < li | x > ls)
}
# Mostrar la tabla de valores atípicos
knitr::kable(
tabla_atipicos,
digits = 2,
caption = "Detección de valores atípicos mediante el criterio de Tukey"
)
| Variable | Limite_inferior | Limite_superior | N_atipicos |
|---|---|---|---|
| age | 28.5 | 80.5 | 0 |
| trestbps | 90.0 | 170.0 | 9 |
| chol | 112.5 | 380.5 | 5 |
| thalach | 83.5 | 215.5 | 1 |
Tambien lo podemos hacer graficamente usando boxplots:
# Crear una cuadrícula de 2 filas y 2 columnas
par(mfrow = c(2, 2), mar = c(4, 4, 3, 2))
variables <- names(variables_cuantitativas)
unidades <- c("Años", "mmHg", "mg/dL", "Latidos/min")
for (i in seq_along(variables)) {
boxplot(
variables_cuantitativas[[i]],
main = paste("Boxplot de", variables[i]),
ylab = unidades[i],
col = "#7EBEA3",
border = "#347A65",
pch = 19,
cex = 0.8
)
}
# Restaurar la configuración gráfica
par(mfrow = c(1, 1))
Para evaluar la asimetría de las distribuciones, calculamos el coeficiente de asimetría (skewness):
# Calcular el coeficiente de asimetría de cada variable
tabla_asimetria <- data.frame(
Variable = names(variables_cuantitativas),
Asimetria = sapply(
variables_cuantitativas,
moments::skewness,
na.rm = TRUE
),
row.names = NULL
)
# Mostrar los resultados
knitr::kable(
tabla_asimetria,
digits = 3,
caption = "Coeficientes de asimetría de las variables cuantitativas"
)
| Variable | Asimetria |
|---|---|
| age | -0.163 |
| trestbps | 0.719 |
| chol | 1.177 |
| thalach | -0.525 |
Esto anterior tambien lo podemos verificar miranod los histrogramas:
# Crear una cuadrícula de 2 filas y 2 columnas
par(mfrow = c(2, 2), mar = c(4, 4, 3, 2))
for (i in seq_along(variables)) {
x <- variables_cuantitativas[[i]]
hist(
x,
breaks = "FD",
probability = TRUE,
main = paste("Distribución de", variables[i]),
xlab = unidades[i],
ylab = "Densidad",
col = "#85B8E0",
border = "white"
)
# Añadir curva de densidad estimada
lines(
density(x, na.rm = TRUE),
col = "#C0392B",
lwd = 2
)
# Añadir media y mediana
abline(v = mean(x), col = "#2471A3", lwd = 2, lty = 2)
abline(v = median(x), col = "#229954", lwd = 2, lty = 3)
legend(
"topright",
legend = c("Media", "Mediana", "Densidad"),
col = c("#2471A3", "#229954", "#C0392B"),
lty = c(2, 3, 1),
lwd = 2,
bty = "n",
cex = 0.75
)
}
par(mfrow = c(1, 1))
A partir de los resultados obtenidos, podemos observar que las cuatro variables presentan diferentes comportamientos:
age: la media (54,43) y la mediana (55) son muy próximas. El coeficiente de asimetría (−0,163) suguiere que la distribución es aproximadamente simétrica. No se han detectado valores atípicos según el criterio de Tukey.
trestbps: presenta una media de 131,34 mmHg y una mediana de 130 mmHg. El coeficiente de asimetría (0,719) indica una asimetría positiva moderada, con una cola hacia valores elevados. Se han detectado 9 valores atípicos superiores a 170 mmHg.
chol: presenta una media de 249,66 mg/dL y una mediana de 245 mg/dL. Es la variable con mayor asimetría positiva (1,177), lo que indica una cola más pronunciada hacia valores elevados de colesterol. Se han identificado 5 valores atípicos superiores a 380,5 mg/dL, con un máximo de 564 mg/dL.
thalach: presenta una media de 149,68 y una mediana de 153,5 latidos por minuto. El coeficiente de asimetría (−0,525) indica una asimetría negativa moderada. Se ha detectado un valor atípico inferior a 83,5 latidos por minuto.
En conclusión, la variable age presenta una distribución
aproximadamente simétrica, mientras que trestbps y
chol muestran asimetría positiva y thalach
asimetría negativa. La variable trestbps presenta el mayor
número de valores atípicos, aunque chol tiene el
coeficiente de asimetría más elevado.
Finalmente, debemos tener en cuenta que los valores atípicos identificados son observaciones estadísticamente extremas, pero no necesariamente errores en los datos.
# Convertir target en una variable categórica con etiquetas
grupo_enfermedad <- factor(
HeartStat$target,
levels = c(0, 1),
labels = c("Sin enfermedad", "Con enfermedad")
)
# Diagrama de cajas
boxplot(
HeartStat$thalach ~ grupo_enfermedad,
col = c("#7EBEA3", "#3498DB"),
border = "#347A65",
main = "Frecuencia cardíaca máxima según enfermedad cardíaca",
xlab = "Diagnóstico de enfermedad cardíaca",
ylab = "Frecuencia cardíaca máxima (latidos/min)",
pch = 19
)
A partir del diagrama de cajas, observamos que los pacientes sin
enfermedad cardíaca presentan una mediana de frecuencia cardíaca máxima
de aproximadamente 160 latidos por minuto, mientras que los pacientes
con enfermedad cardíaca presentan una mediana próxima a 141 latidos por
minuto.
También observamos que existe cierta dispersión en ambos grupos y que sus distribuciones se solapan parcialmente. Además, aparecen algunos valores atípicos en la parte inferior de ambas distribuciones.
Por tanto, podemos concluir que, en esta muestra, los pacientes con enfermedad cardíaca tienden a presentar una frecuencia cardíaca máxima alcanzada inferior a la de los pacientes sin enfermedad.
Sin embargo, esta relación es descriptiva y no permite establecer causalidad ni afirmar que la diferencia sea estadísticamente significativa sin realizar un contraste adicional.
Tenemos dos variables:
Primero calculamos las frecuencias absolutas mediante table()
# Construir la tabla de frecuencias cruzadas
tabla_restecg_target <- table(
restecg = HeartStat$restecg,
target = HeartStat$target
)
# Asignar nombres descriptivos a las columnas
colnames(tabla_restecg_target) <- c(
"Sin enfermedad",
"Con enfermedad"
)
# Asignar nombres descriptivos a las filas
rownames(tabla_restecg_target) <- c(
"ECG 0",
"ECG 1",
"ECG 2"
)
# Mostrar la tabla
knitr::kable(
tabla_restecg_target,
caption = "Frecuencias cruzadas entre ECG en reposo y enfermedad cardíaca"
)
| Sin enfermedad | Con enfermedad | |
|---|---|---|
| ECG 0 | 85 | 46 |
| ECG 1 | 1 | 1 |
| ECG 2 | 64 | 73 |
Utilizaremos ggplot2, que permite construir un mapa de calor con las frecuencias escritas dentro de cada celda.
# Convertir la tabla de contingencia en data frame
datos_heatmap <- as.data.frame(tabla_restecg_target)
# Mantener el orden de las categorías
datos_heatmap$target <- factor(
datos_heatmap$target,
levels = c("Sin enfermedad", "Con enfermedad")
)
# Construir el mapa de calor
ggplot2::ggplot(
datos_heatmap,
ggplot2::aes(
x = target,
y = restecg,
fill = Freq
)
) +
ggplot2::geom_tile(color = "white", linewidth = 1) +
ggplot2::geom_text(
ggplot2::aes(label = Freq),
size = 5
) +
ggplot2::scale_fill_gradient(
low = "#E3F2FD",
high = "#1565C0"
) +
ggplot2::labs(
title = "ECG en reposo y presencia de enfermedad cardíaca",
x = "Diagnóstico de enfermedad cardíaca",
y = "Resultado del ECG en reposo",
fill = "Pacientes"
) +
ggplot2::theme_minimal(base_size = 12)
A partir de la tabla de frecuencias cruzadas y del mapa de calor,
observamos que la combinación más frecuente corresponde a los pacientes
con restecg = 0 y sin enfermedad cardíaca, con 85
observaciones.
En los pacientes con restecg = 2, encontramos una mayor
frecuencia de diagnósticos positivos (73) que negativos (64). Por el
contrario, en restecg = 0 predominan los diagnósticos
negativos (85 frente a 46).
La categoría restecg = 1 presenta únicamente dos
observaciones, por lo que no permite extraer conclusiones
relevantes.
Estos resultados sugieren diferencias en la distribución del diagnóstico según el resultado del electrocardiograma en reposo.
Lo primero es seleccionar los pacientes con enfermedad cardiaca. (arget = 1 identifica a los pacientes con diagnóstico positivo.)
# Seleccionar el colesterol de pacientes con enfermedad cardíaca
chol_enfermedad <- na.omit(
HeartStat$chol[HeartStat$target == 1]
)
# Comprobar el tamaño de la muestra y sus estadísticas
length(chol_enfermedad)
## [1] 120
summary(chol_enfermedad)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 149.0 227.2 255.5 256.5 286.5 409.0
sd(chol_enfermedad)
## [1] 47.96917
Antes del código, conviene entender qué estamos comparando.
Si ambas curvas tienen formas similares, tenemos evidencia visual compatible con la normalidad. Si presentan diferencias importantes, la hipótesis de normalidad resulta menos plausible.
# Estadísticos de los pacientes con enfermedad
media_chol <- mean(chol_enfermedad)
sd_chol <- sd(chol_enfermedad)
# Histograma en escala de densidad
hist(
chol_enfermedad,
breaks = "FD",
probability = TRUE,
col = "#85B8E0",
border = "white",
main = "Distribución del colesterol en pacientes con enfermedad cardíaca",
xlab = "Colesterol (mg/dL)",
ylab = "Densidad"
)
# Curva de densidad empírica
lines(
density(chol_enfermedad),
col = "#C0392B",
lwd = 2
)
# Curva de densidad normal teórica
curve(
dnorm(x, mean = media_chol, sd = sd_chol),
add = TRUE,
col = "#2471A3",
lwd = 2,
lty = 2
)
# Leyenda
legend(
"topright",
legend = c("Densidad empírica", "Normal teórica"),
col = c("#C0392B", "#2471A3"),
lty = c(1, 2),
lwd = 2,
bty = "n"
)
El gráfico es una primera aproximación, pero podemos complementarlo con dos procedimientos estadísticos:
Gráfico Q-Q: Un gráfico Q-Q compara los cuantiles observados con los que esperaríamos bajo una distribución normal.
Test de Shapiro-Wilk: donde \[H_0:\text{Los datos proceden de una distribución normal}\]
\[H_1:\text{Los datos no proceden de una distribución normal}\] interpretación habitual con un nivel de significación del 5 %: - Si p-valor menor que 0,05, rechazamos la hipótesis de normalidad. - Si p-valor mayor o igual que 0,05, no rechazamos la hipótesis de normalidad, pero eso no demuestra que los datos sean normales.
A. Gráfico Q-Q:
qqnorm(
chol_enfermedad,
main = "Q-Q plot del colesterol en pacientes con enfermedad",
pch = 19,
col = "#3498DB"
)
qqline(
chol_enfermedad,
col = "#C0392B",
lwd = 2
)
B. Test de Shapiro-Wilk:
# Contraste de normalidad de Shapiro-Wilk
shapiro.test(chol_enfermedad)
##
## Shapiro-Wilk normality test
##
## data: chol_enfermedad
## W = 0.98607, p-value = 0.2558
A partir del histograma observamos que la distribución del colesterol en los pacientes con enfermedad cardíaca presenta una forma aproximadamente acampanada. La curva de densidad empírica y la curva normal teórica muestran un ajuste visual bastante próximo, especialmente en la parte central de la distribución.
En el gráfico Q-Q observamos que la mayoría de los puntos siguen aproximadamente la línea de referencia, aunque existen algunas desviaciones en la cola derecha, correspondientes a valores elevados de colesterol.
Para complementar el análisis gráfico, realizamos el test de normalidad de Shapiro-Wilk, obteniendo un estadístico W = 0,98607 y un p-valor = 0,2558.
Dado que el p-valor es superior al nivel de significación del 5 %, no rechazamos la hipótesis nula de normalidad.
Por tanto, podemos concluir que la distribución del colesterol en los pacientes con enfermedad cardíaca es razonablemente compatible con una distribución normal, aunque presenta algunas desviaciones en sus valores extremos. Esto no implica que hayamos demostrado la normalidad, sino que no encontramos evidencia estadística suficiente para rechazarla.
Analizamos las variables cuantitativas age, trestbps, chol, thalach y oldpeak, junto con ca, que es cuantitativa discreta y representa el número de vasos principales observados mediante fluoroscopia. Las variables categóricas se excluyen aunque estén codificadas mediante números.
El coeficiente de Pearson (\(r\)) toma valores entre −1 y +1:
# Seleccionar las variables cuantitativas del dataset
variables_correlacion <- HeartStat[, c(
"age", "trestbps", "chol", "thalach", "oldpeak", "ca"
)]
# Calcular la matriz de correlación de Pearson
matriz_correlacion <- cor(
variables_correlacion,
method = "pearson",
use = "complete.obs"
)
# Mostrar la matriz con tres decimales
knitr::kable(
matriz_correlacion,
digits = 3,
caption = "Matriz de correlación de Pearson"
)
| age | trestbps | chol | thalach | oldpeak | ca | |
|---|---|---|---|---|---|---|
| age | 1.000 | 0.273 | 0.220 | -0.402 | 0.194 | 0.356 |
| trestbps | 0.273 | 1.000 | 0.173 | -0.039 | 0.223 | 0.086 |
| chol | 0.220 | 0.173 | 1.000 | -0.019 | 0.028 | 0.127 |
| thalach | -0.402 | -0.039 | -0.019 | 1.000 | -0.349 | -0.265 |
| oldpeak | 0.194 | 0.223 | 0.028 | -0.349 | 1.000 | 0.255 |
| ca | 0.356 | 0.086 | 0.127 | -0.265 | 0.255 | 1.000 |
representamos gráficamente la matriz:
# Representar la matriz de correlación
corrplot::corrplot(
matriz_correlacion,
method = "color",
type = "upper",
order = "original",
addCoef.col = "black",
number.digits = 2,
tl.col = "black",
tl.srt = 45,
diag = FALSE,
col = colorRampPalette(
c("#C0392B", "white", "#2471A3")
)(200)
)
La correlación lineal más fuerte en valor absoluto corresponde a age y thalach, con r = −0,402. Esta asociación negativa de magnitud moderada indica que, en esta muestra, los pacientes de mayor edad tienden a alcanzar una frecuencia cardíaca máxima menor. El resultado es coherente con la disminución de la frecuencia cardíaca máxima asociada al envejecimiento.
La correlación positiva más elevada se observa entre age y ca (r = 0,356): los pacientes de mayor edad tienden a presentar valores más altos en el número de vasos principales observados mediante fluoroscopia. También destaca la asociación negativa entre thalach y oldpeak (r = −0,349), que indica que una mayor depresión del segmento ST inducida por el ejercicio tiende a acompañarse de una menor frecuencia cardíaca máxima alcanzada.