Pregunta. ¿Cómo cambia el área de las hojas según el lugar donde crece el árbol? Comparamos árboles del campus según su exposición a la luz: sol (luz directa la mayor parte del día, sin sombra de otros árboles ni edificios) o sombra (en caso contrario).
Hipótesis (escrita antes de salir a campo). Los árboles expuestos al sol tienen hojas de mayor área que los de sombra, porque con más luz captan más energía y fijan más carbono para expandir la lámina foliar. La hipótesis nula es que el área foliar no difiere entre ambas exposiciones.
Se trazó el contorno de cada hoja a lápiz sobre papel blanco y se dibujó con regla un marco rectangular cuyos lados se midieron en mm. El papel se fotografió y el área (cm²) se estimó usando enteramente un código brindado por el docente de la asignatura, que corrige la perspectiva usando el marco como escala.
ARCHIVO <- "hojas - diseño - tabla de datos.csv"
SALIDA <- "base_datos_area_foliar_revisada.csv"
if (!file.exists(ARCHIVO)) {
stop("No encuentro '", ARCHIVO, "'. Pon el .Rmd y el CSV en la misma carpeta ",
"(en RStudio: Session > Set Working Directory > To Source File Location).", call. = FALSE)
}
# Todo se lee como texto y se convierte después, para controlar cada columna.
crudo <- read.csv(ARCHIVO, colClasses = "character", check.names = FALSE,
fileEncoding = "UTF-8-BOM")
names(crudo) <- tolower(trimws(names(crudo))) # la guía pide nombres en minúscula
crudo[] <- lapply(crudo, trimws)
esperadas <- c("grupo", "id_arbol", "lat", "lon", "n_hoja", "area_cm2",
"exposicion", "fecha", "observaciones")
faltan <- setdiff(esperadas, names(crudo))
if (length(faltan) > 0) stop("Faltan columnas en la base: ", paste(faltan, collapse = ", "), call. = FALSE)
# Separador decimal mixto ("51,63" y "51.64"): se cuenta antes de unificar
n_punto <- sum(grepl(".", crudo$area_cm2, fixed = TRUE))
# "3,377" y "3.377" -> 3.377 ; si algo no es numérico, se detiene con un mensaje claro
num <- function(v, nombre) {
r <- suppressWarnings(as.numeric(gsub(",", ".", v, fixed = TRUE)))
if (any(is.na(r) & v != "")) stop("La columna '", nombre, "' tiene valores no numéricos.", call. = FALSE)
r
}
datos <- data.frame(
grupo = as.integer(num(crudo$grupo, "grupo")),
id_arbol = crudo$id_arbol,
lat = num(crudo$lat, "lat"),
lon = num(crudo$lon, "lon"),
n_hoja = as.integer(num(crudo$n_hoja, "n_hoja")),
area_cm2 = num(crudo$area_cm2, "area_cm2"),
exposicion = tolower(crudo$exposicion),
fecha = as.Date(crudo$fecha, tryFormats = c("%d/%m/%Y", "%Y-%m-%d")),
observaciones = ifelse(crudo$observaciones == "", NA_character_, crudo$observaciones),
stringsAsFactors = FALSE
)haversine <- function(lat1, lon1, lat2, lon2) { # distancia en metros entre dos puntos GPS
rad <- pi / 180; R <- 6371000
a <- sin((lat2 - lat1) * rad / 2)^2 +
cos(lat1 * rad) * cos(lat2 * rad) * sin((lon2 - lon1) * rad / 2)^2
2 * R * asin(sqrt(a))
}
coords <- unique(datos[, c("id_arbol", "lat", "lon")])
D <- outer(seq_len(nrow(coords)), seq_len(nrow(coords)),
function(i, j) haversine(coords$lat[i], coords$lon[i], coords$lat[j], coords$lon[j]))
dimnames(D) <- list(coords$id_arbol, coords$id_arbol)
dist_min <- min(D[upper.tri(D)])
arboles_exp <- unique(datos[, c("id_arbol", "exposicion")])
chequeos <- list()
revisar <- function(descripcion, ok) {
chequeos[[length(chequeos) + 1]] <<- data.frame(
revision = descripcion, cumple = ifelse(isTRUE(ok), "sí", "NO"))
invisible(ok)
}
sin_na <- setdiff(names(datos), "observaciones")
revisar("Sin valores faltantes (excepto 'observaciones')", !anyNA(datos[, sin_na]))
revisar("El ID tiene el formato G#_T#", all(grepl("^G[0-9]+_T[0-9]+$", datos$id_arbol)))
revisar("El número del grupo coincide con el ID",
isTRUE(all(suppressWarnings(as.integer(sub("^G([0-9]+)_T.*$", "\\1", datos$id_arbol))) == datos$grupo)))
revisar("Hay 5 árboles", length(unique(datos$id_arbol)) == 5)
revisar("Cada árbol tiene exactamente 10 hojas", all(table(datos$id_arbol) == 10))
revisar("n_hoja va de 1 a 10 sin repetirse dentro de cada árbol",
all(tapply(datos$n_hoja, datos$id_arbol, function(v) identical(sort(v), 1:10))))
revisar("Todas las áreas son números mayores que 0", all(is.finite(datos$area_cm2) & datos$area_cm2 > 0))
revisar("exposicion solo toma los valores 'sol' o 'sombra'", all(datos$exposicion %in% c("sol", "sombra")))
revisar("Cada árbol tiene una sola exposición", all(tapply(datos$exposicion, datos$id_arbol, function(v) length(unique(v))) == 1))
revisar("Cada árbol tiene un solo par de coordenadas",
all(tapply(paste(datos$lat, datos$lon), datos$id_arbol, function(v) length(unique(v))) == 1))
revisar("Hay una sola fecha de recolección y es válida", !anyNA(datos$fecha) && length(unique(datos$fecha)) == 1)
revisar("Al menos 2 árboles a pleno sol y 2 a la sombra",
sum(arboles_exp$exposicion == "sol") >= 2 && sum(arboles_exp$exposicion == "sombra") >= 2)
revisar("Ningún par de árboles a menos de 5 m (descarta un árbol medido dos veces)", dist_min > 5)
tabla_chequeos <- do.call(rbind, chequeos)
knitr::kable(tabla_chequeos, col.names = c("Revisión", "¿Cumple?"),
caption = "Revisión de la base de datos")| Revisión | ¿Cumple? |
|---|---|
| Sin valores faltantes (excepto ‘observaciones’) | sí |
| El ID tiene el formato G#_T# | sí |
| El número del grupo coincide con el ID | sí |
| Hay 5 árboles | sí |
| Cada árbol tiene exactamente 10 hojas | sí |
| n_hoja va de 1 a 10 sin repetirse dentro de cada árbol | sí |
| Todas las áreas son números mayores que 0 | sí |
| exposicion solo toma los valores ‘sol’ o ‘sombra’ | sí |
| Cada árbol tiene una sola exposición | sí |
| Cada árbol tiene un solo par de coordenadas | sí |
| Hay una sola fecha de recolección y es válida | sí |
| Al menos 2 árboles a pleno sol y 2 a la sombra | sí |
| Ningún par de árboles a menos de 5 m (descarta un árbol medido dos veces) | sí |
if (any(tabla_chequeos$cumple == "NO")) {
stop("Hay revisiones que no se cumplen; corrige la base antes de continuar.", call. = FALSE)
}La base pasó las 13 revisiones. Un valor de área (1 en total). La distancia mínima entre árboles es de 26 m.
# Se ordenan los árboles (sol primero) para las gráficas y se guarda la base revisada
datos$exposicion <- factor(datos$exposicion, levels = c("sol", "sombra"))
orden <- unique(as.character(datos$id_arbol[order(datos$exposicion, datos$id_arbol)]))
datos$id_arbol <- factor(datos$id_arbol, levels = orden)
datos <- datos[order(datos$id_arbol, datos$n_hoja), ]
rownames(datos) <- NULL
# Misma estructura de la plantilla (columnas y valores de la guía), con punto decimal en todo
write.csv(transform(datos, id_arbol = as.character(id_arbol),
exposicion = as.character(exposicion), fecha = format(fecha, "%d/%m/%Y")),
SALIDA, row.names = FALSE, na = "", fileEncoding = "UTF-8")resumen <- function(x) c(n = length(x), media = mean(x), mediana = median(x),
sd = sd(x), cv_pct = 100 * sd(x) / mean(x))
tabla_arbol <- do.call(rbind, lapply(split(datos, datos$id_arbol), function(d) {
data.frame(id_arbol = as.character(d$id_arbol[1]),
exposicion = as.character(d$exposicion[1]),
t(resumen(d$area_cm2)))
}))
rownames(tabla_arbol) <- NULL
knitr::kable(tabla_arbol, digits = 2,
col.names = c("Árbol", "Exposición", "n", "Media (cm²)", "Mediana (cm²)", "Desv. est. (cm²)", "CV (%)"),
caption = "Área foliar por árbol (10 hojas por árbol)")| Árbol | Exposición | n | Media (cm²) | Mediana (cm²) | Desv. est. (cm²) | CV (%) |
|---|---|---|---|---|---|---|
| G1_T1 | sol | 10 | 66.34 | 63.99 | 16.34 | 24.63 |
| G1_T2 | sol | 10 | 60.78 | 61.00 | 5.65 | 9.29 |
| G1_T3 | sol | 10 | 82.32 | 82.83 | 11.58 | 14.07 |
| G1_T4 | sombra | 10 | 19.03 | 16.73 | 4.75 | 24.97 |
| G1_T5 | sombra | 10 | 19.47 | 19.65 | 5.90 | 30.32 |
pch_arbol <- setNames(rep_len(c(15, 16, 17, 18, 8), nlevels(datos$id_arbol)), levels(datos$id_arbol))
op <- par(mar = c(4.2, 4.6, 3, 1), las = 1, bty = "l")
boxplot(area_cm2 ~ id_arbol, data = datos, boxwex = 0.6, border = "grey30",
col = unname(col_exp[as.character(tabla_arbol$exposicion)]),
xlab = "Árbol", ylab = expression("Área foliar (cm"^2*")"),
main = "Área foliar por árbol", outline = FALSE)
stripchart(area_cm2 ~ id_arbol, data = datos, vertical = TRUE, method = "jitter",
jitter = 0.12, pch = 16, col = rgb(0, 0, 0, 0.45), add = TRUE)
legend("topright", fill = col_exp, legend = c("Sol", "Sombra"), bty = "n")Área foliar por árbol. Cada punto es una hoja.
set.seed(1)
datos$x_exp <- as.integer(datos$exposicion)
op <- par(mar = c(4.2, 4.6, 3, 1), las = 1, bty = "l")
boxplot(area_cm2 ~ exposicion, data = datos, col = unname(col_exp), border = "grey30",
outline = FALSE, names = c("Sol", "Sombra"), xlab = "Exposición",
ylab = expression("Área foliar (cm"^2*")"), main = "Área foliar por exposición")
points(datos$x_exp + runif(nrow(datos), -0.18, 0.18), datos$area_cm2,
pch = unname(pch_arbol[as.character(datos$id_arbol)]), col = rgb(0, 0, 0, 0.55))
points(as.integer(medias$exposicion), medias$area_cm2, pch = 23, bg = "red", cex = 1.8)
legend("topright", legend = c(levels(datos$id_arbol), "promedio del árbol"),
pch = c(unname(pch_arbol), 23), pt.bg = c(rep(NA, length(pch_arbol)), "red"),
bty = "n", cex = 0.8)Área foliar por exposición. Puntos = hojas (símbolo según árbol); rombos rojos = promedio de cada árbol.
Cada hoja se compara con las de su propio árbol, con dos criterios:
Una hoja marcada es solo candidata. Se eliminaría únicamente con evidencia de error de medición (trazo dudoso o foto defectuosa); si no, se conserva porque puede ser variación natural.
datos$z_arbol <- ave(datos$area_cm2, datos$id_arbol,
FUN = function(x) (x - mean(x)) / sd(x))
datos$z_mod <- ave(datos$area_cm2, datos$id_arbol, FUN = function(x) {
mad_x <- mad(x, constant = 1) # mediana de las desviaciones absolutas
if (mad_x == 0) return(rep(0, length(x)))
0.6745 * (x - median(x)) / mad_x
})
datos$atipico_tukey <- ave(datos$area_cm2, datos$id_arbol,
FUN = function(x) as.numeric(x %in% boxplot.stats(x)$out)) == 1
datos$atipico_z <- abs(datos$z_mod) > 3.5
candidatas <- datos[datos$atipico_tukey | datos$atipico_z,
c("id_arbol", "n_hoja", "area_cm2", "z_arbol", "z_mod", "atipico_tukey", "atipico_z", "observaciones")]
if (nrow(candidatas) == 0) {
cat("Ninguna hoja fue marcada por los dos criterios.\n")
} else {
knitr::kable(candidatas, digits = 2, row.names = FALSE,
col.names = c("Árbol", "Hoja", "Área (cm²)", "z (media, sd)", "z modificado",
"Marca Tukey", "Marca |z mod|>3.5", "Observaciones"),
caption = "Hojas candidatas a atípicas")
}| Árbol | Hoja | Área (cm²) | z (media, sd) | z modificado | Marca Tukey | Marca |z mod|>3.5 | Observaciones |
|---|---|---|---|---|---|---|---|
| G1_T2 | 1 | 52.18 | -1.52 | -2.76 | TRUE | FALSE | NA |
| G1_T2 | 5 | 71.09 | 1.83 | 3.16 | TRUE | FALSE | NA |
| G1_T2 | 10 | 51.64 | -1.62 | -2.93 | TRUE | FALSE | NA |
n_tukey <- sum(datos$atipico_tukey)
n_z <- sum(datos$atipico_z)
n_obs <- sum(!is.na(datos$observaciones))
max_zmod <- max(abs(datos$z_mod))
if (n_tukey > 0) {
arbol_tukey <- unique(as.character(datos$id_arbol[datos$atipico_tukey]))
cv_tukey <- tabla_arbol$cv_pct[tabla_arbol$id_arbol %in% arbol_tukey]
max_z_tukey <- max(abs(datos$z_arbol[datos$atipico_tukey]))
txt_tukey <- sprintf(
"La regla de Tukey marcó %d hoja(s), todas del árbol %s, que tiene el CV más bajo de la muestra (%.0f %%): con un núcleo tan compacto y solo 10 hojas, la regla se vuelve muy estricta y marca hojas a menos de %.1f desviaciones estándar de la media del árbol.",
n_tukey, paste(arbol_tukey, collapse = ", "), min(cv_tukey), ceiling(max_z_tukey * 10) / 10)
} else {
txt_tukey <- "La regla de Tukey no marcó ninguna hoja."
}
# Sensibilidad: repetir la comparación sin las hojas marcadas por Tukey
datos_sin <- datos[!datos$atipico_tukey, ]
medias_sin <- aggregate(area_cm2 ~ id_arbol + exposicion, data = datos_sin, FUN = mean)
t_sin <- t.test(area_cm2 ~ exposicion, data = medias_sin)
dif_sin <- diff(rev(tapply(medias_sin$area_cm2, medias_sin$exposicion, mean)))Decisión: se conservan las 50 hojas. La regla de
Tukey marcó 3 hoja(s), todas del árbol G1_T2, que tiene el CV más bajo
de la muestra (9 %): con un núcleo tan compacto y solo 10 hojas, la
regla se vuelve muy estricta y marca hojas a menos de 1.9 desviaciones
estándar de la media del árbol. El criterio robusto marcó 0 hoja(s)
(máximo |z modificado| = 3.38) y la columna observaciones
tiene 0 nota(s), es decir, no hay registro de errores de medición. Al
repetir la comparación sin las hojas de Tukey, la diferencia sol −
sombra pasa a 50.9 cm² (p de Welch = 0.014), así que la conclusión no
depende de esas hojas.
Las 10 hojas de un árbol comparten genotipo, edad, suelo y microclima, por lo que no son réplicas independientes. La unidad de análisis es el promedio por árbol.
n_por_cat <- table(medias$exposicion)
n_sol <- as.integer(n_por_cat["sol"]); n_sombra <- as.integer(n_por_cat["sombra"])
knitr::kable(medias, digits = 2, row.names = FALSE,
col.names = c("Árbol", "Exposición", "Área media (cm²)"),
caption = "Promedio de área foliar por árbol (unidad de análisis)")| Árbol | Exposición | Área media (cm²) |
|---|---|---|
| G1_T1 | sol | 66.34 |
| G1_T2 | sol | 60.78 |
| G1_T3 | sol | 82.32 |
| G1_T4 | sombra | 19.03 |
| G1_T5 | sombra | 19.47 |
m_sol <- mean(medias$area_cm2[medias$exposicion == "sol"])
m_sombra <- mean(medias$area_cm2[medias$exposicion == "sombra"])
rng_sol <- range(medias$area_cm2[medias$exposicion == "sol"])
rng_sombra <- range(medias$area_cm2[medias$exposicion == "sombra"])
sin_solape <- rng_sol[1] > rng_sombra[2]
t_arboles <- t.test(area_cm2 ~ exposicion, data = medias) # Welch (varianzas desiguales)
w_arboles <- wilcox.test(area_cm2 ~ exposicion, data = medias, exact = TRUE)
ic <- t_arboles$conf.int
knitr::kable(
data.frame(
prueba = c("t de Welch", "Wilcoxon–Mann–Whitney exacta"),
estadistico = c(sprintf("t = %.2f (gl = %.1f)", t_arboles$statistic, t_arboles$parameter),
sprintf("W = %.0f", w_arboles$statistic)),
p = c(sprintf("%.3f", t_arboles$p.value), sprintf("%.2f", w_arboles$p.value))),
col.names = c("Prueba (sobre promedios por árbol)", "Estadístico", "Valor p"),
caption = "Comparación de sol vs sombra")| Prueba (sobre promedios por árbol) | Estadístico | Valor p |
|---|---|---|
| t de Welch | t = 7.83 (gl = 2.0) | 0.016 |
| Wilcoxon–Mann–Whitney exacta | W = 6 | 0.20 |
# Pseudorreplicación: se calcula solo para mostrar por qué NO se deben usar las 50 hojas
t_hojas <- t.test(area_cm2 ~ exposicion, data = datos)
av <- anova(lm(area_cm2 ~ id_arbol, data = datos))
msb <- av["id_arbol", "Mean Sq"]; msw <- av["Residuals", "Mean Sq"]
k <- mean(table(datos$id_arbol))
icc <- (msb - msw) / (msb + (k - 1) * msw) # correlación intraclase¿Cuántos árboles hay realmente por categoría? 3 de sol y 2 de sombra: el tamaño de muestra efectivo es 5, no 50. Lo confirma la correlación intraclase (ICC = 0.89): el 89 % de la variación del área ocurre entre árboles. Tratar las 50 hojas como independientes daría p = 1.2e-19, un resultado inválido por pseudorreplicación.
El área media por árbol fue de 60.8 a 82.3 cm² en sol y de 19.0 a 19.5 cm² en sombra. Las hojas de sol fueron en promedio 3.6 veces más grandes (diferencia de 50.6 cm²; IC 95 %: 22.8 a 78.3 cm²). Los promedios por árbol de ambas categorías no se solapan. La prueba t de Welch da p = 0.016, pero con solo ≈2 grados de libertad y un intervalo muy ancho. La prueba exacta no depende de la normalidad y su p de 0.20 es el mínimo posible con 3 vs 2 árboles (solo existen 10 ordenamientos), de modo que no puede ser significativa aunque la separación sea total.
Las hojas de sol fueron claramente más grandes que las de sombra, y esa diferencia supera con holgura la variación entre hojas de un mismo árbol (CV por árbol de 9 % a 30 %, sin patrón por exposición). El resultado coincide con la dirección de la hipótesis, pero no la confirma: con 3 y 2 árboles la evidencia es frágil y hay explicaciones alternativas (ver más abajo).
Dentro de una misma especie suele esperarse lo contrario (hojas de sombra más grandes y delgadas, hojas de sol más pequeñas y gruesas). Aquí se comparan árboles distintos, así que ese patrón no se puede evaluar con estos datos.
ids_sol <- as.character(medias$id_arbol[medias$exposicion == "sol"])
ids_som <- as.character(medias$id_arbol[medias$exposicion == "sombra"])
min_dentro <- function(ids) min(D[ids, ids][upper.tri(D[ids, ids])])
d_sol_min <- min_dentro(ids_sol)
d_som_min <- min_dentro(ids_som)
d_entre <- min(D[ids_sol, ids_som])| Resultado (lo respaldan los datos) | Hipótesis (los datos no lo demuestran) |
|---|---|
| Los 3 árboles de sol tuvieron hojas más grandes que los 2 de sombra, en la dirección esperada. | Que la causa sea la luz: la exposición está comprometida por la especie, el sitio y la edad del árbol. |
| La mayor parte de la variación está entre árboles, no entre hojas de un mismo árbol. | Que el patrón se repita en otros árboles o especies del campus. |
| Con 3 y 2 árboles la evidencia estadística es débil (intervalo amplio, p exacto mínimo = 0.2). | Cualquier mecanismo (agua, temperatura, suelo): requeriría medir esas variables. |
## R version 4.5.2 (2025-10-31 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 10 x64 (build 19045)
##
## Matrix products: default
## LAPACK version 3.12.1
##
## locale:
## [1] LC_COLLATE=Spanish_Colombia.utf8 LC_CTYPE=Spanish_Colombia.utf8
## [3] LC_MONETARY=Spanish_Colombia.utf8 LC_NUMERIC=C
## [5] LC_TIME=Spanish_Colombia.utf8
##
## time zone: America/Bogota
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## loaded via a namespace (and not attached):
## [1] digest_0.6.39 R6_2.6.1 fastmap_1.2.0 xfun_0.56
## [5] cachem_1.1.0 knitr_1.51 htmltools_0.5.9 rmarkdown_2.30
## [9] lifecycle_1.0.5 cli_3.6.5 sass_0.4.10 jquerylib_0.1.4
## [13] compiler_4.5.2 rstudioapi_0.18.0 tools_4.5.2 evaluate_1.0.5
## [17] bslib_0.10.0 yaml_2.3.12 otel_0.2.0 jsonlite_2.0.0
## [21] rlang_1.1.7