El estudio evaluado es el de Padidar et al. (2023), titulado Snakebite epidemiology, outcomes and multi-cluster risk modelling in Eswatini. Los autores describieron 932 mordeduras de serpiente registradas entre 2019 y 2021 y desarrollaron modelos epidemiológicos y mapas de riesgo espacial.
El objetivo es evaluar la organización y calidad de las bases públicas, recalcular los principales resultados, identificar posibles errores de captura, análisis o interpretación y proponer mejoras reproducibles.
La revisión diferencia tres tipos de hallazgos:Valores o resultados que no coinciden entre la base pública y el artículo.
Ejemplo: porcentajes, frecuencias o rangos diferentes.
Procedimientos, denominadores o transformaciones que no se describen con suficiente detalle.
Ejemplo: subconjunto espacial no identificado.
Decisiones analíticas que reducen la validez o el alcance de las conclusiones.
Ejemplo: ausencia de población expuesta.
buscar_archivo <- function(nombre) {
candidatos <- c(
nombre,
file.path("data", nombre),
file.path("upload", nombre)
)
encontrados <- candidatos[file.exists(candidatos)]
if (length(encontrados) == 0) {
stop(paste("No se encontró el archivo:", nombre))
}
encontrados[1]
}
ruta_clinica <- buscar_archivo("Eswatini Snakebite Harvard.xlsx")
ruta_geografica <- buscar_archivo(
"Eswatini Snakebite Tin Elevation Harvard.xlsx"
)
df_clinical_raw <- read_excel(ruta_clinica, sheet = 1)
df_elevation_raw <- read_excel(ruta_geografica, sheet = 1)
estructura_bases <- tibble(
base = c("Clínica-epidemiológica", "Geográfica-altitudinal"),
filas = c(nrow(df_clinical_raw), nrow(df_elevation_raw)),
columnas = c(ncol(df_clinical_raw), ncol(df_elevation_raw)),
hoja = c(excel_sheets(ruta_clinica)[1], excel_sheets(ruta_geografica)[1])
)
kable(estructura_bases, caption = "Estructura de las bases públicas")
| base | filas | columnas | hoja |
|---|---|---|---|
| Clínica-epidemiológica | 932 | 29 | Sheet1 |
| Geográfica-altitudinal | 932 | 4 | Snakebite |
La base original se conserva sin modificaciones. La limpieza se realiza sobre copias de trabajo para mantener la trazabilidad.
limpiar_nombres <- function(x) {
x <- trimws(tolower(x))
x <- iconv(x, from = "", to = "ASCII//TRANSLIT")
x <- gsub("[^a-z0-9]+", "_", x)
x <- gsub("^_+|_+$", "", x)
x
}
df_clinical <- df_clinical_raw
df_elevation <- df_elevation_raw
names(df_clinical) <- limpiar_nombres(names(df_clinical))
names(df_elevation) <- limpiar_nombres(names(df_elevation))
columnas_clinicas_esperadas <- c(
"age", "gender", "occupation_type", "date_snakebite",
"time_snakebite", "circumstances_snakebite", "position_snakebite",
"firstaid_any", "clinical_presentation", "av_administered",
"pre_med_adrenaline", "av_vials", "final_outcome"
)
columnas_geo_esperadas <- c("patient", "long", "lat", "elevation")
if (length(setdiff(columnas_clinicas_esperadas, names(df_clinical))) > 0) {
stop("La base clínica no contiene todas las columnas esperadas.")
}
if (length(setdiff(columnas_geo_esperadas, names(df_elevation))) > 0) {
stop("La base geográfica no contiene las cuatro columnas esperadas.")
}
normalizar_si_no <- function(x) {
x_chr <- trimws(tolower(as.character(x)))
case_when(
is.na(x) | x_chr == "" ~ NA_character_,
x_chr == "yes" ~ "Yes",
x_chr == "no" ~ "No",
TRUE ~ trimws(as.character(x))
)
}
variables_si_no <- intersect(
c(
"firstaid_any", "firstaid_tourniquet", "firstaid_bandage",
"firstaid_herbal_applied", "firstaid_herbal_ingested",
"firstaid_incision", "av_administered", "pre_med_adrenaline",
"surgery", "debridement", "fasciotomy", "skin_graft",
"amputation", "surgery_other"
),
names(df_clinical)
)
df_clinical <- df_clinical %>%
mutate(
record_order = row_number(),
age_raw = trimws(as.character(age)),
age_clean = case_when(
age_raw == ".11-19" ~ "10-19",
TRUE ~ age_raw
),
gender = trimws(as.character(gender)),
position_snakebite = trimws(tolower(as.character(position_snakebite))),
circumstances_snakebite = trimws(as.character(circumstances_snakebite)),
clinical_presentation = trimws(as.character(clinical_presentation)),
final_outcome = trimws(as.character(final_outcome)),
date_snakebite = as.Date(date_snakebite)
) %>%
mutate(across(all_of(variables_si_no), normalizar_si_no))
df_elevation <- df_elevation %>%
mutate(
patient = as.integer(patient),
long = as.numeric(long),
lat = as.numeric(lat),
elevation = as.numeric(elevation)
)
secuencia_geo_correcta <- isTRUE(all(
df_elevation$patient == seq_len(nrow(df_elevation))
))
La base clínica no contiene un identificador de paciente. Por ello, no es posible demostrar que cada fila clínica corresponde al mismo paciente de la base geográfica. Para fines exploratorios se conserva el orden original, pero esta unión no debe presentarse como una vinculación confirmada.
df_combined <- df_clinical %>%
left_join(
df_elevation %>%
transmute(
record_order = patient,
patient_geo = patient,
long,
lat,
elevation
),
by = "record_order"
)
control_union <- tibble(
control = c(
"La base geográfica contiene pacientes consecutivos 1-932",
"La base clínica contiene un identificador explícito",
"Número de filas coincidente"
),
resultado = c(
secuencia_geo_correcta,
"patient" %in% names(df_clinical_raw),
nrow(df_clinical_raw) == nrow(df_elevation_raw)
)
)
kable(control_union, caption = "Controles de vinculación de las bases")
| control | resultado |
|---|---|
| La base geográfica contiene pacientes consecutivos 1-932 | TRUE |
| La base clínica contiene un identificador explícito | FALSE |
| Número de filas coincidente | TRUE |
es_faltante <- function(x) {
if (is.character(x)) {
x_limpio <- trimws(tolower(x))
is.na(x) | x_limpio %in% c("", "unknown", "not recorded")
} else {
is.na(x)
}
}
tabla_faltantes <- tibble(
variable = names(df_clinical_raw),
faltantes = vapply(
df_clinical_raw,
function(x) sum(es_faltante(x)),
numeric(1)
)
) %>%
mutate(
porcentaje = 100 * faltantes / nrow(df_clinical_raw)
) %>%
arrange(desc(porcentaje))
variables_clave <- c(
"Age", "Gender", "Occupation_type", "Date_snakebite",
"Time_snakebite", "Circumstances_snakebite", "Position_snakebite",
"Clinical_presentation", "AV_administered", "Final_outcome"
)
tabla_faltantes_clave <- tabla_faltantes %>%
filter(variable %in% variables_clave) %>%
mutate(variable = factor(variable, levels = rev(variables_clave)))
kable(
tabla_faltantes,
digits = 1,
caption = "Cantidad y porcentaje de datos faltantes por variable"
)
| variable | faltantes | porcentaje |
|---|---|---|
| Other | 896 | 96.1 |
| AV_reaction3 | 324 | 34.8 |
| AV_reaction_other | 321 | 34.4 |
| AV_reaction2 | 303 | 32.5 |
| Time_snakebite | 282 | 30.3 |
| Circumstances_snakebite | 194 | 20.8 |
| Occupation_type | 168 | 18.0 |
| Age | 93 | 10.0 |
| FirstAid_Herbal_ingested | 74 | 7.9 |
| Final_outcome | 43 | 4.6 |
| FirstAid_incision | 41 | 4.4 |
| Clinical_presentation | 41 | 4.4 |
| AV_reaction1 | 40 | 4.3 |
| FirstAid_Any | 31 | 3.3 |
| FirstAid_Tourniquet | 31 | 3.3 |
| FirstAid_Bandage | 31 | 3.3 |
| FirstAid_Herbal_applied | 31 | 3.3 |
| Pre-med_adrenaline | 29 | 3.1 |
| AV_vials | 29 | 3.1 |
| Position_snakebite | 25 | 2.7 |
| Surgery | 24 | 2.6 |
| Debridement | 24 | 2.6 |
| Fasciotomy | 24 | 2.6 |
| Skin_graft | 24 | 2.6 |
| Surgery_Other | 24 | 2.6 |
| AV_administered | 23 | 2.5 |
| Amputation | 18 | 1.9 |
| Gender | 0 | 0.0 |
| Date_snakebite | 0 | 0.0 |
Los espacios en blanco de variables de respuesta múltiple, como tipos de primeros auxilios, reacciones y procedimientos quirúrgicos, pueden significar ausencia del evento y no necesariamente pérdida de información. Por esa razón, la gráfica se limita a variables principales y no interpreta automáticamente todos los espacios en blanco como datos faltantes.
geo_completo <- complete.cases(
df_elevation[, c("long", "lat", "elevation")]
)
columnas_reaccion <- intersect(
c("av_reaction1", "av_reaction2", "av_reaction3", "av_reaction_other"),
names(df_clinical)
)
reaccion_registrada <- Reduce(
`|`,
lapply(
df_clinical[columnas_reaccion],
function(x) !es_faltante(as.character(x))
)
)
controles_calidad <- tibble(
control = c(
"Filas completamente duplicadas en la base clínica",
"Filas completamente duplicadas en la base geográfica",
"Registros geográficos completos",
"Registros sin coordenadas ni elevación",
"Pares de coordenadas diferentes",
"Antiveneno administrado con número de viales faltante",
"Reacción registrada en personas sin antiveneno",
"Resultados clínicos finales faltantes"
),
resultado = c(
sum(duplicated(df_clinical_raw)),
sum(duplicated(df_elevation_raw)),
sum(geo_completo),
sum(!geo_completo),
nrow(distinct(filter(df_elevation, geo_completo), long, lat)),
sum(
df_clinical$av_administered == "Yes" & is.na(df_clinical$av_vials),
na.rm = TRUE
),
sum(reaccion_registrada & df_clinical$av_administered != "Yes", na.rm = TRUE),
sum(es_faltante(df_clinical$final_outcome))
)
)
kable(controles_calidad, caption = "Controles básicos de calidad y coherencia")
| control | resultado |
|---|---|
| Filas completamente duplicadas en la base clínica | 0 |
| Filas completamente duplicadas en la base geográfica | 0 |
| Registros geográficos completos | 862 |
| Registros sin coordenadas ni elevación | 70 |
| Pares de coordenadas diferentes | 59 |
| Antiveneno administrado con número de viales faltante | 8 |
| Reacción registrada en personas sin antiveneno | 709 |
| Resultados clínicos finales faltantes | 43 |
La estructura general es rectangular y no presenta duplicados completos. Sin embargo, la calidad se reduce por la ausencia de un identificador compartido, los valores faltantes y las categorías inconsistentes. La repetición de coordenadas demuestra que los registros fueron asignados a localidades o áreas administrativas, por lo que no deben considerarse puntos espaciales independientes.
n_total <- nrow(df_clinical)
tabla_genero <- df_clinical %>%
mutate(gender_plot = if_else(is.na(gender), "Sin dato", gender)) %>%
count(gender_plot, name = "casos") %>%
mutate(porcentaje = 100 * casos / n_total)
orden_edades <- c(
"0-9", "10-19", "20-29", "30-39", "40-49", "50-59",
"60-69", "70-79", "80-89", "90-99", "100-109", "Sin dato"
)
tabla_edad <- df_clinical %>%
mutate(
age_plot = if_else(is.na(age_clean), "Sin dato", age_clean),
age_plot = factor(age_plot, levels = orden_edades)
) %>%
count(age_plot, name = "casos", .drop = FALSE) %>%
mutate(porcentaje = 100 * casos / n_total)
n_menores_30 <- sum(
df_clinical$age_clean %in% c("0-9", "10-19", "20-29"),
na.rm = TRUE
)
n_edad_conocida <- sum(!is.na(df_clinical$age_clean))
resumen_edad <- tibble(
indicador = c(
"Menores de 30 años sobre todos los registros",
"Menores de 30 años entre las edades conocidas"
),
numerador = c(n_menores_30, n_menores_30),
denominador = c(n_total, n_edad_conocida),
porcentaje = 100 * numerador / denominador
)
kable(tabla_genero, digits = 1, caption = "Distribución por género")
| gender_plot | casos | porcentaje |
|---|---|---|
| Female | 421 | 45.2 |
| Male | 511 | 54.8 |
kable(resumen_edad, digits = 1, caption = "Cálculo de pacientes menores de 30 años")
| indicador | numerador | denominador | porcentaje |
|---|---|---|---|
| Menores de 30 años sobre todos los registros | 542 | 932 | 58.2 |
| Menores de 30 años entre las edades conocidas | 542 | 839 | 64.6 |
traducir_presentacion <- function(x) {
case_when(
x == "None: Asymptomatic" ~ "Asintomático",
x == "Mild swelling" ~ "Inflamación leve",
x == "Painful progressive swelling (Cytotoxic)" ~
"Edema progresivo doloroso (citotóxico)",
x == "Progressive weakness (Neurotoxic)" ~
"Debilidad progresiva (neurotóxico)",
x == "Opthalmic (Venom in the eye)" ~ "Exposición oftálmica",
x == "Bleeding (Hemotoxic)" ~ "Sangrado (hemotóxico)",
TRUE ~ x
)
}
tabla_presentacion <- df_clinical %>%
filter(!es_faltante(clinical_presentation)) %>%
mutate(presentacion = traducir_presentacion(clinical_presentation)) %>%
count(presentacion, name = "casos") %>%
mutate(porcentaje = 100 * casos / sum(casos)) %>%
arrange(desc(casos))
kable(
tabla_presentacion,
digits = 1,
caption = "Presentaciones clínicas entre los registros con información disponible"
)
| presentacion | casos | porcentaje |
|---|---|---|
| Asintomático | 329 | 36.9 |
| Inflamación leve | 277 | 31.1 |
| Edema progresivo doloroso (citotóxico) | 218 | 24.5 |
| Debilidad progresiva (neurotóxico) | 44 | 4.9 |
| Exposición oftálmica | 21 | 2.4 |
| Sangrado (hemotóxico) | 2 | 0.2 |
De los registros con presentación clínica conocida, los cuadros asintomáticos y la inflamación leve fueron los más frecuentes. La gráfica describe categorías registradas y no permite identificar por sí sola la especie responsable ni confirmar la gravedad clínica individual.
tabla_antiveneno <- df_clinical %>%
mutate(
av_plot = case_when(
av_administered == "Yes" ~ "Sí",
av_administered == "No" ~ "No",
TRUE ~ "Sin dato"
)
) %>%
count(av_plot, name = "casos") %>%
mutate(porcentaje = 100 * casos / n_total)
tabla_desenlace <- df_clinical %>%
mutate(
outcome_plot = case_when(
final_outcome == "Full recovery, no permanent damage" ~ "Recuperación completa",
final_outcome == "Death" ~ "Fallecimiento",
final_outcome == "Permanent disability" ~ "Discapacidad permanente",
TRUE ~ "Sin dato"
)
) %>%
count(outcome_plot, name = "casos") %>%
mutate(porcentaje = 100 * casos / n_total)
viales_tratados <- df_clinical %>%
filter(av_administered == "Yes", !is.na(av_vials))
resumen_viales <- tibble(
pacientes_tratados = sum(df_clinical$av_administered == "Yes", na.rm = TRUE),
pacientes_con_viales_registrados = nrow(viales_tratados),
media_viales = mean(viales_tratados$av_vials),
mediana_viales = median(viales_tratados$av_vials),
minimo = min(viales_tratados$av_vials),
maximo = max(viales_tratados$av_vials)
)
kable(tabla_antiveneno, digits = 1, caption = "Administración de antiveneno")
| av_plot | casos | porcentaje |
|---|---|---|
| No | 709 | 76.1 |
| Sin dato | 23 | 2.5 |
| Sí | 200 | 21.5 |
kable(tabla_desenlace, digits = 1, caption = "Desenlace clínico final")
| outcome_plot | casos | porcentaje |
|---|---|---|
| Discapacidad permanente | 8 | 0.9 |
| Fallecimiento | 10 | 1.1 |
| Recuperación completa | 871 | 93.5 |
| Sin dato | 43 | 4.6 |
kable(resumen_viales, digits = 2, caption = "Número de viales entre pacientes con información disponible")
| pacientes_tratados | pacientes_con_viales_registrados | media_viales | mediana_viales | minimo | maximo |
|---|---|---|---|---|---|
| 200 | 192 | 5.19 | 5 | 1 | 25 |
tabla_av_presentacion <- df_clinical %>%
filter(
!es_faltante(clinical_presentation),
av_administered %in% c("Yes", "No")
) %>%
mutate(
presentacion = traducir_presentacion(clinical_presentation),
antiveneno = recode(av_administered, "Yes" = "Sí", "No" = "No")
) %>%
count(presentacion, antiveneno, name = "casos") %>%
group_by(presentacion) %>%
mutate(
total = sum(casos),
porcentaje = 100 * casos / total
) %>%
ungroup()
tabla_cirugia_presentacion <- df_clinical %>%
filter(
!es_faltante(clinical_presentation),
surgery %in% c("Yes", "No")
) %>%
mutate(
presentacion = traducir_presentacion(clinical_presentation),
cirugia = recode(surgery, "Yes" = "Sí", "No" = "No")
) %>%
count(presentacion, cirugia, name = "casos") %>%
group_by(presentacion) %>%
mutate(
total = sum(casos),
porcentaje = 100 * casos / total
) %>%
ungroup()
La administración de antiveneno se concentró principalmente en los registros con debilidad progresiva y edema doloroso progresivo. Esta distribución muestra una asociación clínica, pero no demuestra por sí sola que todas las decisiones terapéuticas hayan sido apropiadas.
Las intervenciones quirúrgicas se concentraron en la presentación citotóxica. Los registros faltantes no fueron reclasificados como “No”, evitando reducir artificialmente la proporción de cirugía.
tabla_primeros_auxilios <- tibble(
tipo = c(
"Sin primeros auxilios",
"Torniquete",
"Vendaje",
"Hierbas aplicadas",
"Hierbas ingeridas",
"Incisión",
"Otro procedimiento"
),
casos = c(
sum(df_clinical$firstaid_any == "No", na.rm = TRUE),
sum(df_clinical$firstaid_tourniquet == "Yes", na.rm = TRUE),
sum(df_clinical$firstaid_bandage == "Yes", na.rm = TRUE),
sum(df_clinical$firstaid_herbal_applied == "Yes", na.rm = TRUE),
sum(df_clinical$firstaid_herbal_ingested == "Yes", na.rm = TRUE),
sum(df_clinical$firstaid_incision == "Yes", na.rm = TRUE),
sum(!es_faltante(as.character(df_clinical$other)))
)
) %>%
mutate(porcentaje = 100 * casos / n_total)
kable(
tabla_primeros_auxilios,
digits = 1,
caption = "Frecuencia de primeros auxilios registrados"
)
| tipo | casos | porcentaje |
|---|---|---|
| Sin primeros auxilios | 286 | 30.7 |
| Torniquete | 508 | 54.5 |
| Vendaje | 16 | 1.7 |
| Hierbas aplicadas | 43 | 4.6 |
| Hierbas ingeridas | 138 | 14.8 |
| Incisión | 83 | 8.9 |
| Otro procedimiento | 36 | 3.9 |
Una persona pudo recibir más de un procedimiento, por lo que estas frecuencias no deben sumarse para obtener un total general. Los espacios en blanco de las variables múltiples tampoco se interpretaron automáticamente como ausencia.
tabla_mensual <- df_clinical %>%
filter(!is.na(date_snakebite)) %>%
mutate(mes = as.Date(format(date_snakebite, "%Y-%m-01"))) %>%
count(mes, name = "casos") %>%
arrange(mes)
mes_maximo <- tabla_mensual %>%
slice_max(casos, n = 1, with_ties = FALSE)
kable(mes_maximo, caption = "Mes con el mayor número de registros en la base pública")
| mes | casos |
|---|---|
| 2020-12-01 | 103 |
presentaciones_indicacion <- c(
"Painful progressive swelling (Cytotoxic)",
"Progressive weakness (Neurotoxic)",
"Bleeding (Hemotoxic)"
)
n_sin_av_con_indicacion <- sum(
df_clinical$av_administered == "No" &
df_clinical$clinical_presentation %in% presentaciones_indicacion,
na.rm = TRUE
)
n_retirados <- sum(df_clinical$occupation_type == 14, na.rm = TRUE)
n_primer_auxilio_no <- sum(df_clinical$firstaid_any == "No", na.rm = TRUE)
n_otro_auxilio <- sum(!es_faltante(as.character(df_clinical$other)))
n_premed_si <- sum(
df_clinical$av_administered == "Yes" &
df_clinical$pre_med_adrenaline == "Yes",
na.rm = TRUE
)
n_premed_no <- sum(
df_clinical$av_administered == "Yes" &
df_clinical$pre_med_adrenaline == "No",
na.rm = TRUE
)
n_premed_faltante <- sum(
df_clinical$av_administered == "Yes" &
is.na(df_clinical$pre_med_adrenaline),
na.rm = TRUE
)
n_reaccion_premed_si <- sum(
df_clinical$av_administered == "Yes" &
df_clinical$pre_med_adrenaline == "Yes" & reaccion_registrada,
na.rm = TRUE
)
n_reaccion_premed_no <- sum(
df_clinical$av_administered == "Yes" &
df_clinical$pre_med_adrenaline == "No" & reaccion_registrada,
na.rm = TRUE
)
discapacidad <- df_clinical$final_outcome == "Permanent disability"
n_fasciotomia_discapacidad <- sum(
discapacidad & df_clinical$fasciotomy == "Yes",
na.rm = TRUE
)
elevacion_disponible <- df_elevation$elevation[geo_completo]
tabla_discrepancias <- tibble(
indicador = c(
"Pacientes menores de 30 años",
"Pacientes retirados",
"RR de 30-39 años e IC95%",
"Pacientes sin primeros auxilios",
"Otros primeros auxilios",
"Sin antiveneno pese a indicación clínica",
"Premedicación entre los 200 tratados",
"Reacción al antiveneno según premedicación",
"Fasciotomías entre pacientes con discapacidad",
"Registros con coordenadas y elevación",
"Rango de elevación",
"Mes de mayor frecuencia"
),
publicado = c(
"55%",
"6%",
"1.68 (0.97-1.40)",
"289 en la Tabla S2",
"39 en la Tabla S2",
"105",
"167 sí y 33 no",
"39% con premedicación y 15% sin premedicación",
"3",
"821",
"71-1499 m",
"Enero"
),
recalculado = c(
sprintf("%d/%d = %.1f%%", n_menores_30, n_total, 100 * n_menores_30 / n_total),
sprintf("%d/%d = %.1f%%", n_retirados, n_total, 100 * n_retirados / n_total),
"El estimador puntual queda fuera del intervalo",
as.character(n_primer_auxilio_no),
as.character(n_otro_auxilio),
as.character(n_sin_av_con_indicacion),
sprintf("%d sí, %d no y %d faltantes", n_premed_si, n_premed_no, n_premed_faltante),
sprintf(
"%.1f%% (%d/%d) frente a %.1f%% (%d/%d); %d sin dato de premedicación",
100 * n_reaccion_premed_si / n_premed_si,
n_reaccion_premed_si,
n_premed_si,
100 * n_reaccion_premed_no / n_premed_no,
n_reaccion_premed_no,
n_premed_no,
n_premed_faltante
),
as.character(n_fasciotomia_discapacidad),
as.character(sum(geo_completo)),
sprintf("%.0f-%.0f m", min(elevacion_disponible), max(elevacion_disponible)),
sprintf("%s (%d casos)", format(mes_maximo$mes, "%B de %Y"), mes_maximo$casos)
),
evaluacion = c(
"Porcentaje incorrecto o denominador no declarado",
"Error en la redacción del artículo",
"Error matemático o tipográfico",
"La cifra publicada produce un total imposible de 935",
"No coincide con la base pública",
"El total 105 excluye los dos registros hemotóxicos mencionados en el texto",
"Los datos faltantes fueron tratados aparentemente como ausencia",
"El grupo sin premedicación reproducible es 27, no 33; el 15% no se reproduce",
"Inconsistencia entre la base y el texto",
"No se reproduce el subconjunto espacial publicado",
"No se reproduce con el archivo público",
"No se reproduce con las fechas públicas"
)
)
kable(
tabla_discrepancias,
caption = "Comparación entre los resultados publicados y los recalculados"
)
| indicador | publicado | recalculado | evaluacion |
|---|---|---|---|
| Pacientes menores de 30 años | 55% | 542/932 = 58.2% | Porcentaje incorrecto o denominador no declarado |
| Pacientes retirados | 6% | 33/932 = 3.5% | Error en la redacción del artículo |
| RR de 30-39 años e IC95% | 1.68 (0.97-1.40) | El estimador puntual queda fuera del intervalo | Error matemático o tipográfico |
| Pacientes sin primeros auxilios | 289 en la Tabla S2 | 286 | La cifra publicada produce un total imposible de 935 |
| Otros primeros auxilios | 39 en la Tabla S2 | 36 | No coincide con la base pública |
| Sin antiveneno pese a indicación clínica | 105 | 107 | El total 105 excluye los dos registros hemotóxicos mencionados en el texto |
| Premedicación entre los 200 tratados | 167 sí y 33 no | 167 sí, 27 no y 6 faltantes | Los datos faltantes fueron tratados aparentemente como ausencia |
| Reacción al antiveneno según premedicación | 39% con premedicación y 15% sin premedicación | 91.6% (153/167) frente a 88.9% (24/27); 6 sin dato de premedicación | El grupo sin premedicación reproducible es 27, no 33; el 15% no se reproduce |
| Fasciotomías entre pacientes con discapacidad | 3 | 4 | Inconsistencia entre la base y el texto |
| Registros con coordenadas y elevación | 821 | 862 | No se reproduce el subconjunto espacial publicado |
| Rango de elevación | 71-1499 m | 149-1406 m | No se reproduce con el archivo público |
| Mes de mayor frecuencia | Enero | diciembre de 2020 (103 casos) | No se reproduce con las fechas públicas |
La comparación visual conserva separados los seis pacientes sin información de premedicación. “Sin reacción registrada” significa que las columnas de reacción están vacías; no garantiza que el paciente haya sido evaluado y confirmado como libre de reacción.
La Figura 3 muestra diez categorías de circunstancias y nueve sitios anatómicos. Una tabla de 10 por 9 debería tener 72 grados de libertad, pero el artículo informa 64. Para edad y sitio anatómico ocurre una discrepancia similar: la figura muestra diez grupos de edad y nueve sitios, mientras el texto informa solamente 24 grados de libertad.
df_chi <- df_clinical %>%
mutate(
circunstancia_agrupada = case_when(
circumstances_snakebite %in% c(
"Indoors_sleeping", "Outside_sleeping", "Sleeping"
) ~ "Sleeping",
circumstances_snakebite %in% c("Indoors_home", "Indoors") ~ "Indoors",
circumstances_snakebite %in% c(
"Indoors_walking", "Outside_walking", "Walking"
) ~ "Walking",
circumstances_snakebite %in% c(
"Outside_object", "Indoors_object", "Object"
) ~ "Object",
circumstances_snakebite %in% c(
"Outside_homestead", "Outside"
) ~ "Outdoors",
circumstances_snakebite %in% c(
"Outside_homestead_playing", "Outside_playing"
) ~ "Playing",
circumstances_snakebite == "Outside_water" ~ "Water",
circumstances_snakebite == "Outside_toilet" ~ "Toilet",
circumstances_snakebite %in% c(
"Farm_work", "Outside_work", "Indoors_work", "Work"
) ~ "Work",
circumstances_snakebite %in% c("Inside_car", "Misc") ~ "Misc",
TRUE ~ NA_character_
),
edad_figura = case_when(
age_clean %in% c("90-99", "100-109") ~ "90+",
TRUE ~ age_clean
)
) %>%
mutate(
circunstancia_es = case_when(
circunstancia_agrupada == "Sleeping" ~ "Dormir",
circunstancia_agrupada == "Indoors" ~ "Dentro de la vivienda",
circunstancia_agrupada == "Walking" ~ "Caminar",
circunstancia_agrupada == "Object" ~ "Manipular objetos",
circunstancia_agrupada == "Outdoors" ~ "Exterior del hogar",
circunstancia_agrupada == "Playing" ~ "Jugar",
circunstancia_agrupada == "Water" ~ "Actividad acuática",
circunstancia_agrupada == "Toilet" ~ "Uso del sanitario",
circunstancia_agrupada == "Work" ~ "Trabajo",
circunstancia_agrupada == "Misc" ~ "Otros",
TRUE ~ NA_character_
),
posicion_agrupada = case_when(
position_snakebite %in% c("foot", "leg") ~ "Miembro inferior",
position_snakebite %in% c("hand", "arm") ~ "Miembro superior",
position_snakebite %in% c("head", "eye") ~ "Cabeza y ojos",
position_snakebite %in% c("buttock", "torso", "multiple") ~
"Tronco u otros",
TRUE ~ NA_character_
)
)
tabla_circunstancia <- table(
df_chi$circunstancia_agrupada,
df_chi$position_snakebite,
useNA = "no"
)
tabla_edad_sitio <- table(
df_chi$edad_figura,
df_chi$position_snakebite,
useNA = "no"
)
chi_circunstancia <- suppressWarnings(
chisq.test(tabla_circunstancia, correct = FALSE)
)
chi_edad <- suppressWarnings(
chisq.test(tabla_edad_sitio, correct = FALSE)
)
set.seed(20260904)
p_mc_circunstancia <- chisq.test(
tabla_circunstancia,
simulate.p.value = TRUE,
B = 9999
)$p.value
set.seed(20260904)
p_mc_edad <- chisq.test(
tabla_edad_sitio,
simulate.p.value = TRUE,
B = 9999
)$p.value
cramer_v <- function(prueba, tabla) {
sqrt(
as.numeric(prueba$statistic) /
(sum(tabla) * min(nrow(tabla) - 1, ncol(tabla) - 1))
)
}
resumen_chi <- tibble(
asociacion = c("Circunstancia × sitio", "Edad × sitio"),
dimensiones = c(
paste(dim(tabla_circunstancia), collapse = " × "),
paste(dim(tabla_edad_sitio), collapse = " × ")
),
chi2_recalculado = c(
as.numeric(chi_circunstancia$statistic),
as.numeric(chi_edad$statistic)
),
gl_recalculados = c(
as.numeric(chi_circunstancia$parameter),
as.numeric(chi_edad$parameter)
),
gl_publicados = c(64, 24),
porcentaje_esperadas_menor_5 = c(
100 * mean(chi_circunstancia$expected < 5),
100 * mean(chi_edad$expected < 5)
),
cramer_v = c(
cramer_v(chi_circunstancia, tabla_circunstancia),
cramer_v(chi_edad, tabla_edad_sitio)
),
p_monte_carlo = c(p_mc_circunstancia, p_mc_edad)
)
kable(
resumen_chi,
digits = 4,
caption = "Reevaluación de las asociaciones presentadas en la Figura 3"
)
| asociacion | dimensiones | chi2_recalculado | gl_recalculados | gl_publicados | porcentaje_esperadas_menor_5 | cramer_v | p_monte_carlo |
|---|---|---|---|---|---|---|---|
| Circunstancia × sitio | 10 × 9 | 315.8023 | 72 | 64 | 75.5556 | 0.2324 | 0.0001 |
| Edad × sitio | 10 × 9 | 131.2191 | 72 | 24 | 74.4444 | 0.1414 | 0.0144 |
La gran proporción de frecuencias esperadas menores de cinco incumple el supuesto clásico del chi-cuadrado. Una alternativa es definir agrupaciones clínicamente justificadas antes del análisis y calcular la significancia por simulación de Monte Carlo. El artículo también debería explicar exactamente qué categorías fueron combinadas para obtener los grados de libertad publicados.
El uso de Kruskal-Wallis y comparaciones de Dunn tampoco se encuentra descrito con suficiente detalle. Estas pruebas no deben aplicarse directamente a variables nominales codificadas con números, porque los códigos no representan una escala cuantitativa.
evaluacion_modelos <- tribble(
~componente, ~debilidad_observada, ~mejora_necesaria,
"Unidad de análisis", "La base pública contiene pacientes, pero los predictores territoriales pertenecen a áreas", "Publicar una base por área con el conteo de casos, la población y el tiempo de observación",
"Denominador", "Los casos observados por sí solos no permiten calcular incidencia", "Incluir población-tiempo como offset y áreas con cero casos",
"Dependencia espacial", "Numerosos pacientes comparten las mismas coordenadas", "Modelar la agrupación por área y evaluar autocorrelación espacial de los residuos",
"Agrupamiento", "No se documentan con suficiente detalle la elección del algoritmo, el número de grupos y su estabilidad", "Comparar soluciones, justificar el número de grupos y evaluar estabilidad por remuestreo",
"Probabilidad", "Un índice normalizado o una pertenencia a conglomerados se interpreta como probabilidad de riesgo", "Reservar el término probabilidad para predicciones calibradas contra desenlaces observados",
"Desenlace adverso", "No se muestra una validación directa del índice contra la variable final_outcome", "Definir el desenlace, separar entrenamiento y validación y reportar discriminación y calibración",
"MaxEnt", "No se publican todos los detalles de sesgo de muestreo, selección de fondo, ajuste y validación espacial", "Reportar esos parámetros y utilizar validación cruzada espacial",
"Incertidumbre", "Los mapas se presentan sin intervalos ni estabilidad de clasificación", "Agregar intervalos, mapas de incertidumbre y análisis de sensibilidad"
)
kable(
evaluacion_modelos,
caption = "Evaluación de reproducibilidad e interpretación de los modelos espaciales"
)
| componente | debilidad_observada | mejora_necesaria |
|---|---|---|
| Unidad de análisis | La base pública contiene pacientes, pero los predictores territoriales pertenecen a áreas | Publicar una base por área con el conteo de casos, la población y el tiempo de observación |
| Denominador | Los casos observados por sí solos no permiten calcular incidencia | Incluir población-tiempo como offset y áreas con cero casos |
| Dependencia espacial | Numerosos pacientes comparten las mismas coordenadas | Modelar la agrupación por área y evaluar autocorrelación espacial de los residuos |
| Agrupamiento | No se documentan con suficiente detalle la elección del algoritmo, el número de grupos y su estabilidad | Comparar soluciones, justificar el número de grupos y evaluar estabilidad por remuestreo |
| Probabilidad | Un índice normalizado o una pertenencia a conglomerados se interpreta como probabilidad de riesgo | Reservar el término probabilidad para predicciones calibradas contra desenlaces observados |
| Desenlace adverso | No se muestra una validación directa del índice contra la variable final_outcome | Definir el desenlace, separar entrenamiento y validación y reportar discriminación y calibración |
| MaxEnt | No se publican todos los detalles de sesgo de muestreo, selección de fondo, ajuste y validación espacial | Reportar esos parámetros y utilizar validación cruzada espacial |
| Incertidumbre | Los mapas se presentan sin intervalos ni estabilidad de clasificación | Agregar intervalos, mapas de incertidumbre y análisis de sensibilidad |
Por tanto, los mapas publicados son útiles para generar hipótesis y priorizar áreas, pero no deberían describirse como probabilidades individuales o tasas de incidencia sin una validación adicional.
Se revisó también el panel denominado “Padidar et al. vs. cohorte local” del material complementario del grupo. No se incorporó porque ambas columnas proceden de los mismos 932 registros y varios porcentajes fueron escritos manualmente. Esa comparación sería circular y no constituye una cohorte independiente ni una validación externa.
El modelo glm.nb(patient ~ elevation) utiliza como
respuesta el número consecutivo del paciente. Esto equivale a preguntar
si los identificadores 1, 2, 3, …, 932 cambian con la elevación. El
identificador no es una frecuencia ni una medida de incidencia, por lo
que su coeficiente y valor p carecen de interpretación
epidemiológica.
La base geográfica presenta numerosos pacientes con coordenadas idénticas. Para evitar superponer cientos de puntos, se agruparon los registros por coordenada y elevación. El tamaño del punto representa la cantidad de casos asignados a cada localidad y el color representa su elevación.
La figura muestra concentración de registros en un número reducido de coordenadas. Sin los límites administrativos y la población de cada área no es posible convertir esta distribución en tasas de incidencia ni afirmar que los puntos más grandes representan mayor riesgo poblacional.
La corrección mínima consiste en agrupar los registros que comparten coordenadas y elevación y utilizar como respuesta el número de casos. Este modelo es exploratorio: no estima incidencia porque no incorpora población, tiempo-persona ni localidades con cero casos.
modelo_elevacion <- MASS::glm.nb(
casos ~ I(elevation / 100),
data = geo_localidades
)
coef_altitud <- coef(summary(modelo_elevacion))[2, ]
beta_altitud <- unname(coef_altitud["Estimate"])
se_altitud <- unname(coef_altitud["Std. Error"])
resultado_modelo <- tibble(
localidades = nrow(geo_localidades),
casos_incluidos = sum(geo_localidades$casos),
beta_por_100_m = beta_altitud,
razon_frecuencias = exp(beta_altitud),
cambio_porcentual = 100 * (exp(beta_altitud) - 1),
ic95_inferior = exp(beta_altitud - 1.96 * se_altitud),
ic95_superior = exp(beta_altitud + 1.96 * se_altitud),
valor_p = unname(coef_altitud["Pr(>|z|)"]),
theta = modelo_elevacion$theta,
aic = AIC(modelo_elevacion)
)
kable(
resultado_modelo,
digits = 4,
caption = "Modelo binomial negativo exploratorio por localidad geocodificada"
)
| localidades | casos_incluidos | beta_por_100_m | razon_frecuencias | cambio_porcentual | ic95_inferior | ic95_superior | valor_p | theta | aic |
|---|---|---|---|---|---|---|---|---|---|
| 59 | 862 | -0.0641 | 0.9379 | -6.2057 | 0.8943 | 0.9837 | 0.0084 | 3.0766 | 418.3094 |
En este subconjunto, el modelo estima un cambio de -6.2% en la frecuencia registrada por cada 100 m de elevación. Es una asociación entre localidades que ya aportaron casos; no debe interpretarse como efecto causal ni como razón de tasas poblacionales.
Para estimar incidencia se requeriría una base con todas las áreas censales, incluidas las que registraron cero casos, junto con sus poblaciones y tiempos de observación. La estructura apropiada sería:
modelo_ideal <- MASS::glm.nb(
casos ~ I(elevacion / 100) + pobreza + uso_suelo + acceso_salud +
offset(log(poblacion * anos_observacion)),
data = datos_por_area_censal
)
El exponencial del coeficiente de elevación se interpretaría como razón de tasas de incidencia por cada 100 metros, manteniendo constantes las demás variables.
tabla_mejoras <- tribble(
~componente, ~problema, ~propuesta,
"Organización", "No existe identificador compartido", "Incluir un Patient_ID anónimo en ambas bases",
"Diccionario", "Los códigos de ocupación no están explicados", "Publicar etiquetas, unidades, categorías y códigos faltantes",
"Calidad", "Se confunden ausencia, no aplicación y dato no registrado", "Diferenciar No, No aplica y Sin dato",
"Descriptivos", "Algunos porcentajes no declaran denominador", "Mostrar n/N y porcentaje en cada resultado",
"Incidencia", "Se analizan casos sin población expuesta", "Usar población-tiempo como offset",
"Dependencia espacial", "Muchas observaciones comparten coordenadas", "Agrupar por área y utilizar modelos espaciales o multinivel",
"Chi-cuadrado", "Existen numerosas frecuencias esperadas pequeñas", "Agrupar categorías a priori o usar simulación de Monte Carlo",
"Selección de variables", "El procedimiento escalonado no se describe", "Definir un modelo completo basado en hipótesis y comparar sensibilidad",
"Mapas", "Los índices normalizados se denominan probabilidades", "Denominarlos perfiles relativos o validar un modelo predictivo",
"Reproducibilidad", "No se publicaron scripts ni productos espaciales intermedios", "Compartir código, semillas, versiones y base por área censal"
)
kable(tabla_mejoras, caption = "Matriz de problemas y propuestas de mejora")
| componente | problema | propuesta |
|---|---|---|
| Organización | No existe identificador compartido | Incluir un Patient_ID anónimo en ambas bases |
| Diccionario | Los códigos de ocupación no están explicados | Publicar etiquetas, unidades, categorías y códigos faltantes |
| Calidad | Se confunden ausencia, no aplicación y dato no registrado | Diferenciar No, No aplica y Sin dato |
| Descriptivos | Algunos porcentajes no declaran denominador | Mostrar n/N y porcentaje en cada resultado |
| Incidencia | Se analizan casos sin población expuesta | Usar población-tiempo como offset |
| Dependencia espacial | Muchas observaciones comparten coordenadas | Agrupar por área y utilizar modelos espaciales o multinivel |
| Chi-cuadrado | Existen numerosas frecuencias esperadas pequeñas | Agrupar categorías a priori o usar simulación de Monte Carlo |
| Selección de variables | El procedimiento escalonado no se describe | Definir un modelo completo basado en hipótesis y comparar sensibilidad |
| Mapas | Los índices normalizados se denominan probabilidades | Denominarlos perfiles relativos o validar un modelo predictivo |
| Reproducibilidad | No se publicaron scripts ni productos espaciales intermedios | Compartir código, semillas, versiones y base por área censal |
Los principales conteos demográficos y clínicos pueden reproducirse, pero se identificaron discrepancias numéricas, categorías inconsistentes y porcentajes con denominadores poco claros. Las limitaciones más importantes se concentran en la vinculación de las bases, el manejo de datos faltantes, las pruebas con tablas dispersas y la falta de información necesaria para reproducir el modelo de incidencia publicado.
El agrupamiento espacial realizado por los autores debe interpretarse como una clasificación exploratoria de perfiles territoriales y no como una probabilidad calibrada de mordedura o mal desenlace. La recomendación es reconstruir la base por área censal, incorporar la población expuesta, validar los modelos espacialmente y presentar intervalos de incertidumbre.
La conclusión general no es que todos los resultados del artículo sean incorrectos. La descripción del problema sanitario conserva valor, pero la trazabilidad entre los datos, los análisis y algunas interpretaciones necesita mejorarse.
sessionInfo()
## R version 4.6.1 (2026-06-24 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_Panama.utf8 LC_CTYPE=Spanish_Panama.utf8
## [3] LC_MONETARY=Spanish_Panama.utf8 LC_NUMERIC=C
## [5] LC_TIME=Spanish_Panama.utf8
##
## time zone: America/Panama
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] scales_1.4.0 knitr_1.51 ggplot2_4.0.3 stringr_1.6.0 tidyr_1.3.2
## [6] dplyr_1.2.1 readxl_1.5.0
##
## loaded via a namespace (and not attached):
## [1] gtable_0.3.6 jsonlite_2.0.0 compiler_4.6.1 tidyselect_1.2.1
## [5] dichromat_2.0-1 jquerylib_0.1.4 yaml_2.3.12 fastmap_1.2.0
## [9] R6_2.6.1 labeling_0.4.3 generics_0.1.4 MASS_7.3-66
## [13] tibble_3.3.1 RColorBrewer_1.1-3 bslib_0.12.0 pillar_1.11.1
## [17] rlang_1.3.0 cachem_1.1.0 stringi_1.8.9 xfun_0.60
## [21] S7_0.2.2 sass_0.4.10 otel_0.2.0 cli_3.6.6
## [25] withr_3.0.3 magrittr_2.0.5 digest_0.6.39 grid_4.6.1
## [29] rstudioapi_0.19.0 lifecycle_1.0.5 vctrs_0.7.3 evaluate_1.0.5
## [33] glue_1.8.1 farver_2.1.2 cellranger_1.1.0 stats4_4.6.1
## [37] rmarkdown_2.32 purrr_1.2.2 tools_4.6.1 pkgconfig_2.0.3
## [41] htmltools_0.5.9