Cómo usar este archivo. Guarda este
.Rmd,datos_area_foliar_Mai.csvy (cuando las tengas) la carpetafotos/en la misma carpeta (en RStudio: Session > Set Working Directory > To Source File Location) y pulsa Knit. Las tablas, las gráficas y los números dentro del texto se recalculan solos. Los textos marcados como [COMPLETAR: …] son datos de campo que no están en el Excel (especie, cómo elegiste los árboles, tamaño del marco, etc.): reemplázalos con lo que sí hiciste. Paquetes:knitryrmarkdown(siempre) yleafletpara el mapa interactivo (install.packages("leaflet"); si no está, el informe dibuja un mapa simple).
if (!file.exists(params$archivo)) {
stop("No encuentro el archivo '", params$archivo, "' en la carpeta de este .Rmd (", getwd(), "). ",
"Guarda ahí el CSV con ese nombre exacto, o cambia el nombre en 'params: archivo:' del encabezado.")
}
crudo <- read.csv(params$archivo, stringsAsFactors = FALSE, check.names = FALSE,
na.strings = c("", "NA"), encoding = "UTF-8")
# Nombres de columna tal como vienen del Excel (algunos terminan en un espacio "no separable")
nombres_originales <- names(crudo)
n_nombres_nbsp <- sum(grepl("\u00a0", nombres_originales, fixed = TRUE))
nm <- gsub("\u00a0", " ", nombres_originales, fixed = TRUE)
nm <- tolower(trimws(nm))
nm[nm == "latitud - longitud"] <- "lat_lon"
names(crudo) <- nm
esperadas <- c("grupo", "id_arbol", "lat_lon", "n_hoja", "area_cm2", "exposicion", "fecha")
faltan <- setdiff(esperadas, names(crudo))
if (length(faltan) > 0) stop("Faltan columnas en la base: ", paste(faltan, collapse = ", "))
# El Excel no trae la columna "observaciones" que pide la plantilla: se crea vacía
sin_observaciones <- !"observaciones" %in% names(crudo)
if (sin_observaciones) crudo$observaciones <- NA_character_
fechas <- as.Date(crudo$fecha)
ids <- unique(trimws(crudo$id_arbol)) # en el orden en que aparecen en el archivo
n_arbol <- length(ids)
ficha <- data.frame(
Elemento = c("Pregunta", "Tipo de estudio", "Lugar", "Fechas de recolección",
"Factor de interés", "Variable respuesta", "Unidad experimental",
"Unidad de muestreo", "Tamaño de la muestra", "Instrumentos"),
Descripción = c(
"¿Cómo cambia el área de las hojas según el lugar donde crece el árbol?",
"Observacional y comparativo: no se asignó sol o sombra, se eligieron árboles que ya estaban en cada condición",
"Campus universitario, Cali (Valle del Cauca, Colombia)",
ifelse(min(fechas) == max(fechas), format(min(fechas), "%d/%m/%Y"),
paste(format(min(fechas), "%d/%m/%Y"), "y", format(max(fechas), "%d/%m/%Y"))),
"Exposición del árbol: sol (luz directa la mayor parte del día) o sombra",
"Área de la hoja en cm²",
"El árbol (la exposición es una propiedad suya)",
"La hoja: 10 hojas adultas y sanas por árbol",
paste0(n_arbol, " árboles (", sum(grepl("_S", ids)), " de sombra y ", sum(grepl("_L", ids)),
" de sol); ", nrow(crudo), " filas registradas"),
"Celular (cámara y GPS), papel blanco, lápiz y regla, y un script en R que calcula el área a partir de la foto"))
tabla(ficha, align = "ll")
| Elemento | Descripción |
|---|---|
| Pregunta | ¿Cómo cambia el área de las hojas según el lugar donde crece el árbol? |
| Tipo de estudio | Observacional y comparativo: no se asignó sol o sombra, se eligieron árboles que ya estaban en cada condición |
| Lugar | Campus universitario, Cali (Valle del Cauca, Colombia) |
| Fechas de recolección | 20/10/2026 y 21/10/2026 |
| Factor de interés | Exposición del árbol: sol (luz directa la mayor parte del día) o sombra |
| Variable respuesta | Área de la hoja en cm² |
| Unidad experimental | El árbol (la exposición es una propiedad suya) |
| Unidad de muestreo | La hoja: 10 hojas adultas y sanas por árbol |
| Tamaño de la muestra | 5 árboles (2 de sombra y 3 de sol); 50 filas registradas |
| Instrumentos | Celular (cámara y GPS), papel blanco, lápiz y regla, y un script en R que calcula el área a partir de la foto |
El estudio se hizo en el campus de la universidad, en Cali, en el valle geográfico del río Cauca, una zona tropical con dos temporadas de lluvia al año. El campus cuenta con extensas zonas verdes cargadas de una gran diversidad de vegetación, es por esto que podemos encontrar árboles en diferentes zonas bajo distintas condiciones de exposición solar. En este orden de ideas, podemos encontrar árboles más recubiertos por edificaciones y otros árboles, y otros en zonas más abiertas y despejadas.
El protocolo, en resumen:
Cada punto es un árbol. Haz clic en un punto para ver su foto (si existe) y sus datos. Con la vista de satélite se puede comprobar si el árbol está realmente a cielo abierto o bajo otras copas, que es justo lo que define su exposición.
# Un registro por árbol. Se usa la coordenada de la primera fila de cada árbol (ver la sección 4,
# donde se explica por qué).
primera <- crudo[!duplicated(trimws(crudo$id_arbol)), ]
sitios <- data.frame(id_arbol = trimws(primera$id_arbol),
exposicion = tolower(trimws(primera$exposicion)),
separar_coord(primera$lat_lon), stringsAsFactors = FALSE)
sitios$hojas <- as.integer(table(trimws(crudo$id_arbol))[sitios$id_arbol])
# Dos árboles con la misma coordenada se taparían en el mapa: solo para dibujar, el segundo
# se corre unos 4 m al este (los datos no se modifican)
sitios$lon_mapa <- sitios$lon + ifelse(duplicated(paste(sitios$lat, sitios$lon)), 0.00004, 0)
# Foto de cada árbol: busca fotos/ID.jpg (o .jpeg, .png) y avisa si falta alguna
buscar_foto <- function(id) {
candidatas <- file.path(params$carpeta_fotos, paste0(id, c(".jpg", ".jpeg", ".png")))
existentes <- candidatas[file.exists(candidatas)]
if (length(existentes) > 0) existentes[1] else NA_character_
}
sitios$foto <- vapply(sitios$id_arbol, buscar_foto, character(1))
cat("Árboles:", nrow(sitios), "| con foto:", sum(!is.na(sitios$foto)), "\n")
## Árboles: 5 | con foto: 0
sitios$popup <- vapply(seq_len(nrow(sitios)), function(i) {
s <- sitios[i, ]
img <- if (is.na(s$foto)) "" else paste0("<img src='", knitr::image_uri(s$foto), "' width='200'><br>")
paste0(img, "<b>", s$id_arbol, "</b><br>Exposición: ", s$exposicion,
"<br>Hojas registradas: ", s$hojas, "<br>", fmt(s$lat, 6), "; ", fmt(s$lon, 6))
}, character(1))
if (requireNamespace("leaflet", quietly = TRUE)) {
library(leaflet)
mapa <- leaflet(sitios) %>%
addProviderTiles(providers$Esri.WorldImagery, group = "Satélite") %>%
addTiles(group = "Calles") %>%
addCircleMarkers(data = sitios[sitios$exposicion == "sol", ], lng = ~lon_mapa, lat = ~lat,
radius = 9, weight = 2, color = "white", fillColor = col_expo[["sol"]],
fillOpacity = 0.95, label = ~id_arbol, popup = ~popup,
popupOptions = popupOptions(maxWidth = 240), group = "Sol") %>%
addCircleMarkers(data = sitios[sitios$exposicion == "sombra", ], lng = ~lon_mapa, lat = ~lat,
radius = 9, weight = 2, color = "white", fillColor = col_expo[["sombra"]],
fillOpacity = 0.95, label = ~id_arbol, popup = ~popup,
popupOptions = popupOptions(maxWidth = 240), group = "Sombra") %>%
addLayersControl(baseGroups = c("Satélite", "Calles"), overlayGroups = c("Sol", "Sombra"),
options = layersControlOptions(collapsed = FALSE)) %>%
addLegend("bottomright", colors = unname(col_expo), labels = c("Sol", "Sombra"),
title = "Exposición", opacity = 1) %>%
addScaleBar("bottomleft")
mapa
} else {
cat("El paquete 'leaflet' no está instalado: se muestra un mapa simple.",
"Para el mapa interactivo ejecuta install.packages('leaflet').\n")
plot(sitios$lon_mapa, sitios$lat, pch = 21, cex = 2.5, bg = col_expo[sitios$exposicion],
asp = 1 / cos(mean(sitios$lat) * pi / 180), xlab = "Longitud", ylab = "Latitud",
main = "Ubicación de los árboles")
text(sitios$lon_mapa, sitios$lat, sub("^Mai_", "", sitios$id_arbol), pos = 3, cex = 0.8)
legend("topright", legend = names(col_expo), pt.bg = col_expo, pch = 21, bty = "n")
}
Una buena base de datos incluye una foto de cada árbol: sirve para
comprobar después la clasificación en sol y sombra, y para que el lector
se imagine el sitio. Guarda una foto por árbol en la
carpeta fotos/ con el ID como nombre
(Mai_S1.jpg, Mai_S2.jpg,
Mai_L1.jpg, Mai_L2.jpg,
Mai_L3.jpg): el informe las encuentra solo, las muestra
aquí y las pone en el mapa. Toma la foto de la copa completa y desde el
mismo lugar para todos los árboles. Mientras no estén, aparece “foto
pendiente”.
tarjetas <- vapply(seq_len(nrow(sitios)), function(i) {
s <- sitios[i, ]
img <- if (is.na(s$foto)) "<div class='sinfoto'>foto pendiente</div>"
else paste0("<img src='", knitr::image_uri(s$foto), "'>")
paste0("<figure>", img, "<figcaption><b>", s$id_arbol, "</b> - ", s$exposicion, "</figcaption></figure>")
}, character(1))
cat("\n\n<div class='galeria'>\n", paste(tarjetas, collapse = "\n"), "\n</div>\n\n")
Pregunta. ¿Cómo cambia el área de las hojas según el lugar donde crece el árbol?
Por qué esperamos una diferencia. Una hoja equilibra dos cosas: captar luz y no perder demasiada agua ni calentarse de más. A pleno sol sobra luz y el riesgo es el exceso de calor y de transpiración, así que se esperan hojas más pequeñas. A la sombra la luz es el recurso escaso, y una lámina más grande intercepta más de ella.
Hipótesis: Las hojas de los árboles que crecen a la sombra tienen en promedio mayor área que las de los árboles que crecen a pleno sol. Esto para poder aprovechar más área de recepción de luz solar en ambientes donde esta escaza y que el árbol pueda llevar a cabo procesos de fotosíntesis y crecimiento. Sin embargo, se tiene en cuenta que,la diferencia debería verse en los promedios por árbol y no depender de un solo árbol.
Diseño. Se midieron 5 árboles (2 a la sombra y 3 a pleno sol) y 10 hojas por árbol. Un árbol es de sol si recibe luz directa la mayor parte del día y no lo cubren otros árboles ni edificios; en caso contrario, de sombra. Las hojas fueron adultas, completamente expandidas, sin daño evidente y repartidas por toda la copa.
Medición. Cada hoja se trazó sobre papel, se fotografió dentro de un marco rectangular de lados conocidos y un script en R calculó el área en cm². Los datos se anotaron en la plantilla: una fila por hoja, con árbol, coordenadas, número de hoja, área, exposición y fecha.
Análisis (todo en R base, R version 4.5.2 (2025-10-31 ucrt)):
Antes de calcular nada hay que mirar la base. Cada revisión busca un
tipo de error distinto y se hace sobre una copia (datos):
la base original (crudo) no se toca.
head(crudo, 6) # las primeras filas
## grupo id_arbol lat_lon n_hoja area_cm2 exposicion fecha
## 1 Mai Mai_S1 3.347778 - 76.532778 1 85,65 Sombra 2026-10-20
## 2 Mai Mai_S1 3.347778 - 76.532779 2 81,78 Sombra 2026-10-20
## 3 Mai Mai_S1 3.347778 - 76.532780 3 101,18 Sombra 2026-10-20
## 4 Mai Mai_S1 3.347778 - 76.532781 4 91,99 Sombra 2026-10-20
## 5 Mai Mai_S1 3.347778 - 76.532782 5 95,33 Sombra 2026-10-20
## 6 Mai Mai_S1 3.347778 - 76.532783 6 71,68 Sombra 2026-10-20
## observaciones
## 1 <NA>
## 2 <NA>
## 3 <NA>
## 4 <NA>
## 5 <NA>
## 6 <NA>
sapply(crudo, class) # el tipo de dato que R asignó a cada columna
## grupo id_arbol lat_lon n_hoja area_cm2
## "character" "character" "character" "integer" "numeric"
## exposicion fecha observaciones
## "character" "character" "character"
La base tiene 50 filas y 5 árboles. Si todo hubiera salido perfecto, serían 5 árboles × 10 hojas = 50, que es justo lo que hay. Eso no significa que esté libre de problemas: veamos cuáles encontré.
Nota sobre el archivo Excel. El Excel original tiene
una sola hoja (Hoja1) con 7 columnas con datos. Las celdas
de area_cm2 estaban guardadas como texto
(por eso a_numero() acepta texto con coma o con punto), y a
este informe se le pasa la misma tabla exportada a CSV, sin cambiar
ningún valor.
datos <- crudo
datos$fila <- seq_len(nrow(datos)) # número de fila en el archivo (sin contar el encabezado)
# 1. Nombres de columna: ¿alguno trae el espacio "no separable" que deja el Excel?
data.frame(original = gsub("\u00a0", "<nbsp>", nombres_originales, fixed = TRUE), usado = nm[seq_along(nombres_originales)])
## original usado
## 1 grupo<nbsp> grupo
## 2 id_arbol<nbsp> id_arbol
## 3 Latitud - Longitud lat_lon
## 4 n_hoja<nbsp> n_hoja
## 5 area_cm2<nbsp> area_cm2
## 6 Exposicion<nbsp> exposicion
## 7 Fecha fecha
5 de las 7 columnas terminan en un espacio invisible (no separable),
y una se llama Latitud - Longitud. R no las reconoce con el
nombre que uno escribiría (datos$grupo no funcionaría). Se
limpian los espacios y se pasan a minúsculas.
# 2. ¿El ID tiene el formato Mai_S# o Mai_L# ?
id_mal <- unique(datos$id_arbol[!grepl("^Mai_[SL][0-9]+$", datos$id_arbol)])
id_mal
## character(0)
# 3. ¿De cuántas maneras escribieron "sol" y "sombra"?
table(datos$exposicion, useNA = "ifany")
##
## Sol Sombra
## 30 20
Los IDs tienen el formato correcto (Mai_S# para sombra y
Mai_L# para sol). La exposición está escrita con mayúscula
inicial (Sol, Sombra). Para R,
"Sol" y "sol" son categorías distintas, así
que se normaliza a minúsculas y se comprueba que la letra del ID (S o L)
coincide con la exposición escrita:
n_id_corregido <- sum(datos$id_arbol != trimws(datos$id_arbol))
n_expo_corregida <- sum(datos$exposicion != tolower(trimws(datos$exposicion)))
datos$id_arbol <- trimws(datos$id_arbol)
datos$exposicion <- tolower(trimws(datos$exposicion))
table(datos$exposicion)
##
## sol sombra
## 30 20
stopifnot(all(datos$exposicion %in% c("sol", "sombra")))
# La letra del ID (S = sombra, L = luz/sol) debe coincidir con la exposición registrada
expo_por_id <- ifelse(grepl("_S", datos$id_arbol), "sombra", "sol")
table(id = expo_por_id, columna_exposicion = datos$exposicion)
## columna_exposicion
## id sol sombra
## sol 30 0
## sombra 0 20
stopifnot(all(expo_por_id == datos$exposicion))
# Cada árbol debe tener una sola exposición
n_expo <- tapply(datos$exposicion, datos$id_arbol, function(x) length(unique(x)))
names(n_expo)[n_expo > 1]
## character(0)
# 4. Coordenadas: latitud y longitud vienen juntas en una sola celda de texto
head(unique(datos[, c("id_arbol", "lat_lon")]), 12)
## id_arbol lat_lon
## 1 Mai_S1 3.347778 - 76.532778
## 2 Mai_S1 3.347778 - 76.532779
## 3 Mai_S1 3.347778 - 76.532780
## 4 Mai_S1 3.347778 - 76.532781
## 5 Mai_S1 3.347778 - 76.532782
## 6 Mai_S1 3.347778 - 76.532783
## 7 Mai_S1 3.347778 - 76.532784
## 8 Mai_S1 3.347778 - 76.532785
## 9 Mai_S1 3.347778 - 76.532786
## 10 Mai_S1 3.347778 - 76.532787
## 11 Mai_S2 3.3475 - 76.5325
## 12 Mai_S2 3.3475 - 76.5326
co <- separar_coord(datos$lat_lon)
datos$lat_original <- co$lat
datos$lon_original <- co$lon
summary(datos[, c("lat_original", "lon_original")], digits = 7) # deben caer en la zona del campus
## lat_original lon_original
## Min. :3,347222 Min. :-76,53340
## 1st Qu.:3,347222 1st Qu.:-76,53306
## Median :3,347500 Median :-76,53278
## Mean :3,347444 Mean :-76,53284
## 3rd Qu.:3,347500 3rd Qu.:-76,53270
## Max. :3,347778 Max. :-76,53250
# ¿Cuánto se mueve un mismo árbol de una hoja a otra? (distancia entre su punto más al sur-oeste y más al nor-este, en m)
deriva_m <- sapply(split(datos, datos$id_arbol), function(d)
distancia_m(min(d$lat_original), min(d$lon_original), max(d$lat_original), max(d$lon_original)))
round(deriva_m, 1)
## Mai_L1 Mai_L2 Mai_L3 Mai_S1 Mai_S2
## 1,0 0,0 0,0 1,0 99,9
# ¿Las coordenadas cambian con un paso constante de una fila a la siguiente?
paso_constante <- sapply(split(datos, datos$id_arbol), function(d)
length(unique(round(diff(d$lon_original), 7))) == 1)
paso_constante
## Mai_L1 Mai_L2 Mai_L3 Mai_S1 Mai_S2
## TRUE TRUE TRUE TRUE TRUE
Hay tres problemas con las coordenadas:
3.347778 - 76.532778), y en
un caso con dos espacios y el signo (3.3472222 -76.5327).
El guion se parece al signo menos, pero la longitud de Cali es
oeste, es decir, negativa. La función
separar_coord() saca los dos números y le pone el signo
negativo a la longitud.Mai_S1, Mai_S2 y Mai_L1 los
últimos dígitos de la longitud suben en la misma cantidad en cada fila,
como pasa cuando se arrastra una celda en Excel. Un árbol no se mueve
entre una hoja y otra. En Mai_S2 ese paso suma unos
100 m entre la primera y la última hoja, lo que no
puede ser real. En Mai_S1 y Mai_L1 suma solo
1,0 m: es igual de artificial, pero sin importancia práctica.
(Mai_L2 y Mai_L3 repiten la misma coordenada
en todas sus hojas, que es lo esperable.) Se usa la coordenada
de la primera fila de cada árbol, que es la que se escribió a
mano.Mai_L2 y Mai_L3 tienen exactamente
la misma coordenada. Ver la revisión de árboles repetidos más
abajo.ref <- datos[!duplicated(datos$id_arbol), c("id_arbol", "lat_original", "lon_original")]
datos$lat <- ref$lat_original[match(datos$id_arbol, ref$id_arbol)]
datos$lon <- ref$lon_original[match(datos$id_arbol, ref$id_arbol)]
n_coord_cambiada <- sum(datos$lat != datos$lat_original | datos$lon != datos$lon_original)
if (!is.numeric(datos$area_cm2)) datos$area_cm2 <- a_numero(datos$area_cm2)
n_coord_cambiada
## [1] 27
# 5. Áreas faltantes o no válidas (la plantilla pide área > 0)
datos[is.na(datos$area_cm2) | datos$area_cm2 <= 0,
c("fila", "id_arbol", "n_hoja", "area_cm2", "observaciones")]
## [1] fila id_arbol n_hoja area_cm2 observaciones
## <0 rows> (o 0- extensión row.names)
# 6. Filas repetidas (mismo árbol y mismo número de hoja) y n_hoja fuera de 1-10
repetida <- duplicated(datos[, c("id_arbol", "n_hoja")])
datos[repetida | duplicated(datos[, c("id_arbol", "n_hoja")], fromLast = TRUE),
c("fila", "id_arbol", "n_hoja", "area_cm2")]
## [1] fila id_arbol n_hoja area_cm2
## <0 rows> (o 0- extensión row.names)
range(datos$n_hoja)
## [1] 1 10
# 7. ¿Cuántas hojas tiene cada árbol (sin contar las repetidas)?
n_por_arbol <- table(datos$id_arbol[!repetida])
n_por_arbol
##
## Mai_L1 Mai_L2 Mai_L3 Mai_S1 Mai_S2
## 10 10 10 10 10
# 8. ¿Cuántos árboles hay en cada exposición?
table(unique(datos[, c("id_arbol", "exposicion")])$exposicion)
##
## sol sombra
## 3 2
# 9. ¿Hay valores de área idénticos dentro de un mismo árbol? (pueden ser una coincidencia o una celda copiada)
igual <- datos[duplicated(datos[, c("id_arbol", "area_cm2")]) |
duplicated(datos[, c("id_arbol", "area_cm2")], fromLast = TRUE),
c("fila", "id_arbol", "n_hoja", "area_cm2")]
igual
## fila id_arbol n_hoja area_cm2
## 22 22 Mai_L1 2 70,96
## 24 24 Mai_L1 4 70,96
## 46 46 Mai_L3 6 60,62
## 49 49 Mai_L3 9 60,62
No hay filas repetidas, el número de hoja va de 1 a 10 en todos los
árboles y cada árbol tiene 10 hojas. Hay 4 hojas que comparten el valor
de otra hoja del mismo árbol (por ejemplo, 70,96 cm² en
Mai_L1 y 60,62 cm² en Mai_L3). Con áreas dadas
con dos decimales y 10 hojas por árbol puede ser una coincidencia, pero
también una celda copiada. Como no hay una razón para descartarlas (son
hojas distintas con números de hoja distintos), se
conservan y se comprueba la sensibilidad del resultado en la
sección de valores atípicos.
# 10. Valores extremos respecto a su propio árbol: área / mediana del árbol
mediana_arbol <- ave(datos$area_cm2, datos$id_arbol,
FUN = function(x) median(x[!is.na(x) & x > 0]))
datos$razon_mediana <- datos$area_cm2 / mediana_arbol
range(datos$razon_mediana, na.rm = TRUE)
## [1] 0,7420626 1,6126785
extremos <- datos[!is.na(datos$razon_mediana) & datos$area_cm2 > 0 &
(datos$razon_mediana > 10 | datos$razon_mediana < 0.1),
c("fila", "id_arbol", "n_hoja", "area_cm2", "razon_mediana")]
extremos
## [1] fila id_arbol n_hoja area_cm2 razon_mediana
## <0 rows> (o 0- extensión row.names)
Ninguna hoja vale 10 veces la mediana de su árbol ni una décima parte: no hay errores de unidad (mm² en lugar de cm²), que son los más graves porque multiplican el área por 100. La hoja más extrema respecto a su árbol vale 1,61 veces la mediana de su árbol.
# 11. Observaciones: lo que los propios estudiantes anotaron
tabla(datos[!is.na(datos$observaciones),
c("fila", "id_arbol", "n_hoja", "area_cm2", "observaciones")], row.names = FALSE)
| fila | id_arbol | n_hoja | area_cm2 | observaciones |
|---|
El Excel no tiene la columna
observaciones que pide la plantilla, así que no
hay anotaciones sobre hojas dañadas, fotos dudosas o trazos corregidos.
Se creó vacía. Esto es una limitación: no hay forma de saber por la base
si alguna hoja tuvo un problema de medición. [COMPLETAR: si
recuerdas alguna hoja con trazo dudoso, daño o foto borrosa, anótala
aquí.]
# 12. ¿Cuándo se midió cada árbol?
table(arbol = datos$id_arbol, fecha = datos$fecha)
## fecha
## arbol 2026-10-20 2026-10-21
## Mai_L1 10 0
## Mai_L2 0 10
## Mai_L3 0 10
## Mai_S1 10 0
## Mai_S2 10 0
# 13. ¿Dos árboles con ID distinto son en realidad el mismo? Se compara la distancia entre árboles
arb <- datos[!duplicated(datos$id_arbol), c("id_arbol", "exposicion", "lat", "lon")]
pares <- t(combn(nrow(arb), 2))
d <- distancia_m(arb$lat[pares[, 1]], arb$lon[pares[, 1]], arb$lat[pares[, 2]], arb$lon[pares[, 2]])
dist_pares <- data.frame(arbol_a = arb$id_arbol[pares[, 1]], arbol_b = arb$id_arbol[pares[, 2]],
distancia_m = round(d, 1))
dist_pares[order(dist_pares$distancia_m), ]
## arbol_a arbol_b distancia_m
## 10 Mai_L2 Mai_L3 0,0
## 6 Mai_S2 Mai_L2 38,0
## 7 Mai_S2 Mai_L3 38,0
## 1 Mai_S1 Mai_S2 43,7
## 2 Mai_S1 Mai_L1 43,7
## 8 Mai_L1 Mai_L2 50,2
## 9 Mai_L1 Mai_L3 50,2
## 5 Mai_S2 Mai_L1 61,7
## 3 Mai_S1 Mai_L2 62,4
## 4 Mai_S1 Mai_L3 62,4
# ¿Las hojas de los dos árboles "gemelos" se parecen?
gemelos <- subset(datos, id_arbol %in% c("Mai_L2", "Mai_L3"))
aggregate(area_cm2 ~ id_arbol, gemelos, function(x) round(c(media = mean(x), mediana = median(x), de = sd(x)), 1))
## id_arbol area_cm2.media area_cm2.mediana area_cm2.de
## 1 Mai_L2 78,7 71,8 21,3
## 2 Mai_L3 60,6 60,6 3,2
Mai_L2 y Mai_L3 tienen la misma
coordenada (distancia de 0 m), la misma exposición y la misma
fecha. Podría ser el mismo árbol medido dos veces, pero sus hojas son
muy distintas: la media de Mai_L2 es de 78,7 cm² con una DE
de 21,3, y la de Mai_L3 es de 60,6 cm² con una DE de 3,2. Y
una DE tan baja (CV de 5 %) no se parece a la de ningún otro árbol. Lo
más probable es que sean dos árboles vecinos y que el
segundo reciba las coordenadas del primero (el GPS del celular no
distingue 3 o 4 m). Por eso se mantienen como dos
árboles, con la advertencia de que la coordenada de uno de los
dos es aproximada.
Cada problema se resuelve con una regla explícita, y la fila original
se conserva en la base (usar y motivo) para
poder auditar lo que se hizo.
datos$area_cm2_original <- datos$area_cm2
datos$usar <- TRUE
datos$motivo <- ""
marcar <- function(filas, motivo) { # excluye filas que no tengan ya un motivo
filas <- filas & datos$usar
datos$usar[filas] <<- FALSE
datos$motivo[filas] <<- motivo
sum(filas)
}
danadas <- grepl("mordid|insect|hongo|dañ|roto|rota|herbiv", datos$observaciones, ignore.case = TRUE)
n_repetidas <- marcar(repetida, "fila repetida")
n_sin_area <- marcar(is.na(datos$area_cm2), "sin área")
n_cero <- marcar(!is.na(datos$area_cm2) & datos$area_cm2 <= 0, "área no válida (<= 0)")
n_danadas <- marcar(!is.na(danadas) & danadas, "hoja dañada (el protocolo pide hojas sin daño)")
limpios <- datos[datos$usar, ]
limpios <- limpios[order(match(limpios$id_arbol, ids), limpios$n_hoja), ]
limpios$id_arbol <- factor(limpios$id_arbol, levels = ids)
decisiones <- data.frame(
Problema = c("Nombres de columna con espacio invisible", "Exposición escrita con mayúscula inicial",
"Latitud y longitud juntas en una sola celda de texto",
"Coordenada distinta en cada hoja del mismo árbol (paso fijo, tipo \"arrastrar\" en Excel)",
"Mismas coordenadas en Mai_L2 y Mai_L3", "Área guardada como texto en el Excel",
"Fila repetida", "Área faltante o igual a 0", "Hoja dañada",
"Área en mm² en lugar de cm²", "Valores de área idénticos dentro de un árbol",
"No hay columna de observaciones", "Árboles con menos de 10 hojas"),
Filas = c(paste(n_nombres_nbsp, "columnas"), n_expo_corregida, nrow(crudo), n_coord_cambiada,
sum(datos$id_arbol %in% c("Mai_L2", "Mai_L3")), nrow(crudo),
n_repetidas, n_sin_area + n_cero, n_danadas, 0, nrow(igual),
ifelse(sin_observaciones, "todas", 0), sum(n_por_arbol[n_por_arbol != 10])),
Decisión = c("Quitar los espacios y pasar a minúsculas",
"Pasar a minúsculas (y comprobar que coincide con la letra S o L del ID)",
"Separar en lat y lon; la longitud se toma negativa (oeste)",
"Usar la coordenada de la primera fila del árbol",
"Conservar como dos árboles; coordenada aproximada (se anota en limitaciones)",
"Convertir a número",
"Ninguna fila para eliminar", "Ninguna fila para excluir", "Ninguna fila para excluir (no hay observaciones)",
"Ningún caso: nada que corregir",
"Conservar: son hojas distintas; se revisa la sensibilidad",
"Crear la columna vacía y anotarlo como limitación", "Ninguno: los 5 árboles tienen 10 hojas"))
tabla(decisiones, align = "lrl")
| Problema | Filas | Decisión |
|---|---|---|
| Nombres de columna con espacio invisible | 5 columnas | Quitar los espacios y pasar a minúsculas |
| Exposición escrita con mayúscula inicial | 50 | Pasar a minúsculas (y comprobar que coincide con la letra S o L del ID) |
| Latitud y longitud juntas en una sola celda de texto | 50 | Separar en lat y lon; la longitud se toma negativa (oeste) |
| Coordenada distinta en cada hoja del mismo árbol (paso fijo, tipo “arrastrar” en Excel) | 27 | Usar la coordenada de la primera fila del árbol |
| Mismas coordenadas en Mai_L2 y Mai_L3 | 20 | Conservar como dos árboles; coordenada aproximada (se anota en limitaciones) |
| Área guardada como texto en el Excel | 50 | Convertir a número |
| Fila repetida | 0 | Ninguna fila para eliminar |
| Área faltante o igual a 0 | 0 | Ninguna fila para excluir |
| Hoja dañada | 0 | Ninguna fila para excluir (no hay observaciones) |
| Área en mm² en lugar de cm² | 0 | Ningún caso: nada que corregir |
| Valores de área idénticos dentro de un árbol | 4 | Conservar: son hojas distintas; se revisa la sensibilidad |
| No hay columna de observaciones | todas | Crear la columna vacía y anotarlo como limitación |
| Árboles con menos de 10 hojas | 0 | Ninguno: los 5 árboles tienen 10 hojas |
cat("Filas originales:", nrow(crudo), "\n",
"Filas que se usan:", nrow(limpios), "\n",
"Árboles distintos:", length(unique(limpios$id_arbol)), "\n")
## Filas originales: 50
## Filas que se usan: 50
## Árboles distintos: 5
if (params$guardar_limpios) {
write.csv(limpios[, c("id_arbol", "lat", "lon", "n_hoja", "area_cm2", "exposicion", "fecha", "observaciones")],
"datos_limpios_area_foliar.csv", row.names = FALSE, na = "")
}
Idea clave. Ninguna de estas decisiones es “correcta” por sí sola; lo que importa es que sean explícitas y reproducibles. Quien lea este informe debe poder ver qué se hizo con cada fila dudosa. La base revisada (
datos_limpios_area_foliar.csv) es parte de lo que se entrega.
Desde aquí se usa la base revisada: 5 árboles y 50 hojas.
dg <- limpios
dg$expo <- factor(dg$exposicion, levels = c("sol", "sombra"))
resumen <- do.call(rbind, lapply(split(dg, dg$id_arbol), function(d) {
data.frame(arbol = as.character(d$id_arbol[1]), exposicion = d$exposicion[1], n = nrow(d),
media = mean(d$area_cm2), mediana = median(d$area_cm2),
de = sd(d$area_cm2), cv_pct = cv(d$area_cm2),
minimo = min(d$area_cm2), maximo = max(d$area_cm2))
}))
tabla(resumen, row.names = FALSE, digits = c(0, 0, 0, 1, 1, 1, 1, 1, 1),
col.names = c("Árbol", "Exposición", "n", "Media", "Mediana", "DE", "CV (%)", "Mín.", "Máx."),
caption = "Área foliar (cm²) por árbol. DE = desviación estándar; CV = DE / media.")
| Árbol | Exposición | n | Media | Mediana | DE | CV (%) | Mín. | Máx. |
|---|---|---|---|---|---|---|---|---|
| Mai_S1 | sombra | 10 | 86,4 | 88,8 | 11,9 | 13,8 | 65,9 | 101,2 |
| Mai_S2 | sombra | 10 | 62,7 | 61,8 | 12,3 | 19,5 | 47,0 | 78,7 |
| Mai_L1 | sol | 10 | 61,0 | 59,1 | 13,0 | 21,3 | 45,2 | 85,3 |
| Mai_L2 | sol | 10 | 78,7 | 71,8 | 21,3 | 27,1 | 55,1 | 115,8 |
| Mai_L3 | sol | 10 | 60,6 | 60,6 | 3,2 | 5,2 | 55,5 | 66,2 |
Dos cosas para mirar en esta tabla: (1) la diferencia entre árboles (las medias van de 60,6 a 86,4 cm²), que conviene comparar con la variación dentro de cada árbol (la DE); (2) el CV, que mide cuánto varían las hojas de un árbol respecto a su propia media (aquí entre 5 % en Mai_L3 y 27 % en Mai_L2). Las medias y las medianas de cada árbol son muy parecidas, así que ningún árbol tiene una hoja que lo arrastre.
par(mar = c(4.5, 4.8, 3, 1))
boxplot(area_cm2 ~ id_arbol, data = dg, outline = FALSE, las = 1,
col = adjustcolor(col_expo[resumen$exposicion], 0.55), border = "grey25",
xlab = "Árbol", ylab = expression("Área foliar (cm"^2*")"),
main = "Área foliar por árbol")
stripchart(area_cm2 ~ id_arbol, data = dg, vertical = TRUE, method = "jitter", jitter = 0.12,
pch = 16, col = adjustcolor("black", 0.45), add = TRUE)
legend("topright", legend = c("sol", "sombra"), fill = adjustcolor(col_expo, 0.55), bty = "n")
Área foliar de cada hoja, por árbol. Color = exposición.
Cada caja resume las 10 hojas de un árbol (la línea central es la
mediana y la caja contiene la mitad central de las hojas); los puntos
son las hojas. Se ve que Mai_S1 (sombra) y
Mai_L2 (sol) tienen hojas más grandes, que
Mai_L3 (sol) es muy uniforme y que Mai_S2
(sombra) se parece más a los árboles de sol Mai_L1 y
Mai_L3 que a Mai_S1.
Criterio. Se revisa cada hoja con dos preguntas, en este orden:
fuera <- do.call(rbind, lapply(split(dg, dg$id_arbol), function(d) {
lim <- boxplot.stats(d$area_cm2)$stats[c(1, 5)] # bigotes: límites de Tukey
q <- quantile(d$area_cm2, c(0.25, 0.75)); ric <- diff(q)
o <- d[d$area_cm2 < q[1] - 1.5 * ric | d$area_cm2 > q[2] + 1.5 * ric, ]
if (nrow(o) == 0) return(NULL)
data.frame(arbol = as.character(o$id_arbol), n_hoja = o$n_hoja, area_cm2 = o$area_cm2,
mediana_arbol = median(d$area_cm2), RIC = ric,
limite_inferior = q[1] - 1.5 * ric, limite_superior = q[2] + 1.5 * ric)
}))
if (is.null(fuera) || nrow(fuera) == 0) {
cat("Ninguna hoja queda fuera de los límites de Tukey de su árbol.\n")
} else {
fuera$decision <- "Conservada: sin causa de medición registrada"
tabla(fuera, row.names = FALSE, digits = c(0, 0, 2, 1, 2, 1, 1, 0),
col.names = c("Árbol", "Hoja", "Área (cm²)", "Mediana del árbol", "RIC", "Límite inferior", "Límite superior", "Decisión"),
caption = "Hojas fuera de los límites de Tukey de su árbol, y qué se hizo con cada una.")
}
| Árbol | Hoja | Área (cm²) | Mediana del árbol | RIC | Límite inferior | Límite superior | Decisión |
|---|---|---|---|---|---|---|---|
| Mai_L2 | 3 | 115,75 | 71,8 | 18,35 | 39,5 | 112,9 | Conservada: sin causa de medición registrada |
| Mai_L2 | 10 | 114,33 | 71,8 | 18,35 | 39,5 | 112,9 | Conservada: sin causa de medición registrada |
| Mai_L3 | 2 | 56,83 | 60,6 | 0,98 | 58,3 | 62,3 | Conservada: sin causa de medición registrada |
| Mai_L3 | 3 | 64,73 | 60,6 | 0,98 | 58,3 | 62,3 | Conservada: sin causa de medición registrada |
| Mai_L3 | 5 | 55,51 | 60,6 | 0,98 | 58,3 | 62,3 | Conservada: sin causa de medición registrada |
| Mai_L3 | 10 | 66,24 | 60,6 | 0,98 | 58,3 | 62,3 | Conservada: sin causa de medición registrada |
Ninguna de estas hojas tiene una causa de medición documentada (no hay observaciones ni errores de unidad), de modo que, según el criterio, se conservan todas.
sens <- do.call(rbind, lapply(split(dg, dg$id_arbol), function(d) {
q <- quantile(d$area_cm2, c(0.25, 0.75)); ric <- diff(q)
out <- d$area_cm2 < q[1] - 1.5 * ric | d$area_cm2 > q[2] + 1.5 * ric
data.frame(arbol = as.character(d$id_arbol[1]), exposicion = d$exposicion[1],
media = mean(d$area_cm2), mediana = median(d$area_cm2),
media_sin_atipicas = mean(d$area_cm2[!out]))
}))
dif_sens <- sapply(c(media = "media", mediana = "mediana", media_sin_atipicas = "media_sin_atipicas"),
function(v) mean(sens[[v]][sens$exposicion == "sombra"]) -
mean(sens[[v]][sens$exposicion == "sol"]))
tabla(data.frame(Resumen_por_arbol = c("Media (todas las hojas)", "Mediana",
"Media sin las hojas atípicas"),
Diferencia_sombra_menos_sol = unname(dif_sens)),
digits = 1, col.names = c("Cómo se resume cada árbol", "Sombra − sol (cm²)"),
caption = "Sensibilidad de la diferencia entre exposiciones a las hojas atípicas.")
| Cómo se resume cada árbol | Sombra − sol (cm²) |
|---|---|
| Media (todas las hojas) | 7,8 |
| Mediana | 11,5 |
| Media sin las hojas atípicas | 10,8 |
La diferencia entre sombra y sol (promedio de los árboles de sombra menos promedio de los árboles de sol) va de 7,8 a 11,5 cm² según cómo se resuma cada árbol. Las hojas atípicas no cambian la conclusión: la diferencia sigue siendo positiva y de tamaño parecido, así que el resultado no depende de ellas.
simbolo <- c(16, 17, 15, 18, 8)[as.integer(dg$id_arbol)]
par(mar = c(4.5, 4.8, 3, 1))
boxplot(area_cm2 ~ expo, data = dg, outline = FALSE, las = 1,
col = adjustcolor(col_expo, 0.5), border = "grey25",
xlab = "Exposición", ylab = expression("Área foliar (cm"^2*")"),
main = "Hojas por exposición")
set.seed(2) # para que el "jitter" (separación horizontal de los puntos) sea siempre el mismo
points(as.integer(dg$expo) + runif(nrow(dg), -0.15, 0.15), dg$area_cm2,
pch = simbolo, col = adjustcolor("black", 0.6))
legend("topleft", legend = levels(dg$id_arbol), pch = c(16, 17, 15, 18, 8), bty = "n",
title = "Árbol", cex = 0.85)
Hojas de sol y de sombra. Cada símbolo es un árbol distinto.
Este gráfico es descriptivo: muestra la forma de las dos nubes de hojas, y se ven muy parecidas y con mucho solape. Fíjate en que los símbolos iguales (hojas del mismo árbol) tienden a quedar juntos. Eso es justo lo que hace que las hojas no sean independientes, y lo que impide usarlas como si fueran 50 datos independientes en una prueba.
Lo que se ha asignado a sol o a sombra es el árbol, no la hoja. Las 10 hojas de un mismo árbol comparten suelo, edad, genética y luz. Por eso se promedian y se compara un valor por árbol.
medias <- aggregate(area_cm2 ~ id_arbol + exposicion, data = dg, FUN = mean)
medias$exposicion <- factor(medias$exposicion, levels = c("sol", "sombra"))
medias <- medias[order(medias$exposicion, medias$id_arbol), ]
n_expo_arb <- table(medias$exposicion)
n_expo_arb
##
## sol sombra
## 3 2
tabla(medias, row.names = FALSE, digits = 1,
col.names = c("Árbol", "Exposición", "Media del árbol (cm²)"))
| Árbol | Exposición | Media del árbol (cm²) |
|---|---|---|
| Mai_L1 | sol | 61,0 |
| Mai_L2 | sol | 78,7 |
| Mai_L3 | sol | 60,6 |
| Mai_S1 | sombra | 86,4 |
| Mai_S2 | sombra | 62,7 |
dif_obs <- mean(medias$area_cm2[medias$exposicion == "sombra"]) -
mean(medias$area_cm2[medias$exposicion == "sol"])
dif_obs
## [1] 7,764333
Hay 2 árboles de sombra y 3 de sol: 5 datos independientes en total, no 50. La diferencia observada entre los promedios de árbol es de 7,8 cm² (sombra − sol): la media de los árboles de sombra (74,5 cm²) es mayor que la de los de sol (66,8 cm²), en la dirección de la hipótesis.
Con tan pocos árboles se puede hacer algo mejor que suponer una distribución: una prueba de permutación exacta. Si la exposición no importara, cualquiera de las formas de repartir las etiquetas “sol” y “sombra” entre los árboles sería igual de probable. Se calcula la diferencia para todas ellas y se mira dónde queda la que observamos.
y <- medias$area_cm2
es_sombra <- medias$exposicion == "sombra"
n_som <- sum(es_sombra)
repartos <- combn(length(y), n_som) # todas las formas de elegir los árboles de sombra
dif_perm <- apply(repartos, 2, function(i) mean(y[i]) - mean(y[-i]))
p_perm <- mean(abs(dif_perm) >= abs(dif_obs) - 1e-9)
n_igual_o_mayor <- sum(abs(dif_perm) >= abs(dif_obs) - 1e-9)
par(mar = c(4.5, 4.8, 3, 1))
plot(dif_perm, rep(0, length(dif_perm)), pch = 21, bg = "grey85", cex = 2.2, yaxt = "n",
ylab = "", xlab = "Diferencia sombra - sol (cm²)",
main = paste(ncol(repartos), "repartos posibles"))
points(dif_obs, 0, pch = 21, bg = col_expo["sombra"], cex = 2.6)
abline(v = 0, lty = 3)
legend("topright", legend = "reparto observado", pch = 21, pt.bg = col_expo["sombra"],
bty = "n", pt.cex = 1.6)
Diferencia sombra − sol para cada reparto posible de las etiquetas entre los 5 árboles.
Hay 10 repartos posibles y 5 de ellos dan una diferencia tan grande o más (en valor absoluto) que la observada, así que p = 0,500. El valor más pequeño que cualquier resultado podría dar con 2 y 3 árboles sería 0,100. Con 5 árboles (2 y 3), la prueba de permutación no puede dar resultados “significativos” al 5 %, sean cuales sean los datos.
t_hojas <- t.test(area_cm2 ~ expo, data = dg) # trata las hojas como independientes
p_hojas <- t_hojas$p.value
comparacion <- data.frame(
Análisis = c("Hojas como datos independientes (incorrecto)", "Un promedio por árbol (permutación exacta)"),
n_sol = c(sum(dg$expo == "sol"), sum(!es_sombra)),
n_sombra = c(sum(dg$expo == "sombra"), n_som),
p = c(fmt_p(p_hojas), fmt_p(p_perm)))
tabla(comparacion, col.names = c("Análisis", "n sol", "n sombra", "p"), align = "lrrr")
| Análisis | n sol | n sombra | p |
|---|---|---|---|
| Hojas como datos independientes (incorrecto) | 30 | 20 | 0,115 |
| Un promedio por árbol (permutación exacta) | 3 | 2 | 0,500 |
Tratar las hojas como independientes multiplica por 10 el tamaño de muestra y cambia el p de 0,500 a 0,115. Esa precisión es falsa: se llama pseudorreplicación. Las hojas de un árbol son 10 mediciones de lo mismo, no 10 árboles.
tt <- t.test(area_cm2 ~ exposicion, data = medias) # Welch: sol - sombra
dif <- unname(tt$estimate[2] - tt$estimate[1]) # sombra - sol
ic <- -rev(tt$conf.int)
tl <- t.test(log(area_cm2) ~ exposicion, data = medias) # en escala log: razón de medias geométricas
razon <- unname(exp(tl$estimate[2] - tl$estimate[1]))
ic_raz <- exp(-rev(tl$conf.int))
wil <- wilcox.test(area_cm2 ~ exposicion, data = medias, exact = TRUE)
p_min_wil <- 2 / choose(nrow(medias), n_som)
pruebas <- data.frame(
Análisis = c("Welch sobre promedios de árbol (cm²)",
"Welch sobre log(promedio de árbol): razón sombra / sol",
"Wilcoxon exacto sobre promedios de árbol"),
Estimación = c(paste(fmt(dif), "cm²"), paste0(fmt(razon, 2), " veces"), "-"),
IC95 = c(paste0("[", fmt(ic[1]), "; ", fmt(ic[2]), "]"),
paste0("[", fmt(ic_raz[1], 2), "; ", fmt(ic_raz[2], 2), "]"), "-"),
p = c(fmt_p(tt$p.value), fmt_p(tl$p.value), fmt_p(wil$p.value)))
tabla(pruebas, align = "lrrr",
caption = paste0("Sombra frente a sol con el árbol como unidad (", n_expo_arb["sombra"],
" árboles de sombra, ", n_expo_arb["sol"], " de sol)."))
| Análisis | Estimación | IC95 | p |
|---|---|---|---|
| Welch sobre promedios de árbol (cm²) | 7,8 cm² | [-69,9; 85,5] | 0,633 |
| Welch sobre log(promedio de árbol): razón sombra / sol | 1,11 veces | [0,41; 3,03] | 0,635 |
| Wilcoxon exacto sobre promedios de árbol | - | - | 0,400 |
Se muestran tres formas de comparar el mismo conjunto de promedios por árbol. Como el área es positiva y asimétrica, la comparación en razón (segunda fila) es la más natural: ¿cuántas veces más grande es una hoja de sombra?. Los intervalos de confianza son enormes (con 5 árboles la prueba t tiene solo 1,5 grados de libertad, aprox.): el efecto podría ir desde una razón de 0,41 (hojas de sombra mucho más pequeñas) hasta 3,03 (mucho más grandes). La prueba de Wilcoxon compara rangos y no necesita suponer normalidad, pero con 2 y 3 árboles su p más pequeño posible es de 0,200: no puede dar un resultado significativo.
par(mar = c(4.5, 4.8, 3, 1))
plot(NA, xlim = c(0.5, 2.5), ylim = range(c(resumen$media - resumen$de, resumen$media + resumen$de)),
xaxt = "n", las = 1, xlab = "Exposición", ylab = expression("Área foliar media del árbol (cm"^2*")"),
main = "Un punto por árbol")
axis(1, at = 1:2, labels = c("sol", "sombra"))
x_base <- ifelse(resumen$exposicion == "sol", 1, 2)
x_arb <- x_base + ave(seq_len(nrow(resumen)), resumen$exposicion,
FUN = function(i) seq(-0.15, 0.15, length.out = length(i)))
segments(x_arb, resumen$media - resumen$de, x_arb, resumen$media + resumen$de, col = "grey40", lwd = 2)
points(x_arb, resumen$media, pch = 21, bg = col_expo[resumen$exposicion], cex = 2.2)
text(x_arb, resumen$media, sub("^Mai_", "", resumen$arbol), pos = 4, offset = 1, cex = 0.8)
Cada punto es un árbol (promedio de sus 10 hojas). Las barritas son ±1 DE entre las hojas del árbol.
Hasta aquí comparamos sol y sombra con promedios por árbol y pruebas t, Wilcoxon y de permutación. El análisis de varianza (ANOVA) generaliza esa comparación y permite usar la estructura completa del diseño. En esta sección se responden dos preguntas, cada una con su herramienta:
| Pregunta | Herramienta | Después del ANOVA |
|---|---|---|
| ¿Difieren entre sí los 5 árboles? | ANOVA de un factor (árbol) | Tukey HSD: ¿cuáles árboles? |
| ¿Hay efecto de la exposición respetando que las hojas están dentro de árboles? | ANOVA anidado (árbol dentro de exposición) | Componentes de varianza |
La actividad es un estudio observacional con estructura jerárquica. Cada elemento del diseño tiene un papel:
Mai)
que midió árboles de las dos exposiciones, así que hay un único
bloque: no hay otros grupos con los que comparar y, por eso, no
se puede hacer un ANOVA de bloques, ni estimar la interacción grupo ×
exposición. Todos los efectos se miden dentro del mismo grupo. La fecha
de muestreo tampoco funciona como bloque: el 20/10 se midieron árboles
de sombra y de sol, pero el 21/10 solo de sol (Mai_L2 y
Mai_L3).Con esos elementos, el modelo es
log(área) ~ exposición + árbol(exposición) + error de hoja
El término árbol(exposición) (árboles dentro de cada
exposición) es el error experimental contra el que se
prueba la exposición. Si ese término se ignora y se usa el error de las
hojas, se cae en la pseudorreplicación que vimos antes. El diseño no
está balanceado: hay 2 árboles de sombra y 3 de sol.
tab_diseno <- unique(dg[, c("id_arbol", "exposicion", "fecha")])
tab_diseno$hojas <- as.integer(table(dg$id_arbol)[as.character(tab_diseno$id_arbol)])
tabla(tab_diseno, row.names = FALSE, col.names = c("Árbol (unidad experimental)", "Exposición (factor)", "Fecha", "Hojas (submuestras)"),
caption = "Diseño: 5 unidades experimentales y 10 submuestras en cada una.")
| Árbol (unidad experimental) | Exposición (factor) | Fecha | Hojas (submuestras) |
|---|---|---|---|
| Mai_S1 | sombra | 2026-10-20 | 10 |
| Mai_S2 | sombra | 2026-10-20 | 10 |
| Mai_L1 | sol | 2026-10-20 | 10 |
| Mai_L2 | sol | 2026-10-21 | 10 |
| Mai_L3 | sol | 2026-10-21 | 10 |
¿Por qué el logaritmo del área? El área es positiva y las hojas grandes varían más que las pequeñas. Con el logaritmo, la variabilidad se parece más entre árboles y los efectos se interpretan como razones (por ejemplo, “1,3 veces”). Se puede mirar la relación entre la media de cada árbol y su desviación estándar:
por_arbol <- aggregate(area_cm2 ~ id_arbol, data = dg,
FUN = function(x) c(media = mean(x), de = sd(x)))
por_arbol_log <- aggregate(log(area_cm2) ~ id_arbol, data = dg,
FUN = function(x) c(media = mean(x), de = sd(x)))
r_orig <- cor(por_arbol$area_cm2[, "media"], por_arbol$area_cm2[, "de"])
r_log <- cor(por_arbol_log[[2]][, "media"], por_arbol_log[[2]][, "de"])
c(escala_original = r_orig, escala_log = r_log)
## escala_original escala_log
## 0,4979462 0,1882791
Entre árboles, la correlación entre la media y la DE es de 0,50 en la escala original y de 0,19 con el logaritmo. Con solo 5 árboles estas correlaciones son muy inestables, así que no son una prueba: la razón principal para usar el logaritmo es que el área es una cantidad positiva y que se quiere comparar en razones.
Se compara el log del área de las hojas entre los 5 árboles. Aquí las hojas sí son réplicas (de cada árbol), y la pregunta es por esos árboles concretos, no por la exposición.
m1 <- lm(log(area_cm2) ~ id_arbol, data = dg)
a1 <- tabla_anova(m1)
a1$Fuente <- c("Entre árboles", "Dentro de árboles (hojas)")
tabla(formato_anova(a1), row.names = FALSE, align = "lrrrrr",
caption = "ANOVA de un factor sobre log(área), hojas de los 5 árboles.")
| Fuente | gl | SC | CM | F | p |
|---|---|---|---|---|---|
| Entre árboles | 4 | 1,064 | 0,266 | 7,75 | < 0,001 |
| Dentro de árboles (hojas) | 45 | 1,543 | 0,034 |
Hay diferencias entre árboles: F(4; 45) = 7,8, p < 0,001. El ANOVA solo dice que al menos un árbol difiere; para saber cuáles hay que hacer comparaciones múltiples.
Supuestos. Los residuos deberían ser aproximadamente normales y tener varianza parecida entre árboles.
par(mfrow = c(1, 2), mar = c(4.5, 4.8, 3, 1))
plot(fitted(m1), resid(m1), pch = 16, col = adjustcolor("black", 0.5), las = 1,
xlab = "Valor ajustado (log área)", ylab = "Residuo", main = "Residuos vs ajustados")
abline(h = 0, lty = 2)
qqnorm(resid(m1), pch = 16, col = adjustcolor("black", 0.5), las = 1, main = "Normalidad de los residuos",
xlab = "Cuantiles teóricos", ylab = "Cuantiles de los residuos")
qqline(resid(m1))
Diagnóstico del ANOVA de un factor: residuos frente a valores ajustados (izquierda) y gráfico cuantil-cuantil (derecha).
shap1 <- shapiro.test(resid(m1))
flig1 <- fligner.test(log(area_cm2) ~ id_arbol, data = dg)
kw1 <- kruskal.test(log(area_cm2) ~ id_arbol, data = dg) # alternativa sin supuesto de normalidad
c(shapiro_p = shap1$p.value, fligner_p = flig1$p.value, kruskal_p = kw1$p.value)
## shapiro_p fligner_p kruskal_p
## 0,4421656632 0,0095294432 0,0004792903
La prueba de Shapiro-Wilk (normalidad) da p = 0,442 y la de Fligner-Killeen (igualdad de varianzas) p = 0,010. Un p grande significa que no hay evidencia contra el supuesto; un p pequeño, que sí la hay. Los residuos son compatibles con una distribución normal. En cambio, las varianzas no son iguales entre árboles: la dispersión del log del área es mucho menor en Mai_L3 que en los demás (en el gráfico de residuos, los puntos de ese árbol quedan pegados a cero). Esto afecta sobre todo a las comparaciones en las que participa ese árbol, así que abajo se repiten con una alternativa que no supone varianzas iguales (ANOVA de Welch y pares de Welch con ajuste de Holm). Con pocos datos estas pruebas pueden no detectar un desvío, y con muchos detectan desvíos sin importancia; por eso conviene mirar siempre el gráfico: los puntos deberían seguir la recta y la nube de residuos no debería abrirse como un embudo. La prueba no paramétrica de Kruskal-Wallis (p < 0,001) da la misma conclusión que el ANOVA.
Con 5 árboles hay 10 comparaciones por pares. Si se hicieran 10 pruebas t sueltas al 5 %, la probabilidad de al menos un falso positivo sería de alrededor de 40 %. La prueba de Tukey HSD compara todos los pares y controla ese error para el conjunto completo.
tk <- TukeyHSD(aov(log(area_cm2) ~ id_arbol, data = dg))$id_arbol
tk_tab <- data.frame(comparacion = gsub("-", " / ", rownames(tk)),
razon = exp(tk[, "diff"]), li = exp(tk[, "lwr"]), ls = exp(tk[, "upr"]),
p_aj = tk[, "p adj"], stringsAsFactors = FALSE)
# Tukey devuelve "B-A" (diff = media de B menos media de A): se muestra como razón B / A
tk_tab <- tk_tab[order(tk_tab$p_aj), ]
# ¿Cuántos pares que difieren son de árboles con la MISMA exposición?
pares_ids <- strsplit(tk_tab$comparacion, " / ")
misma_expo <- sapply(pares_ids, function(x) length(unique(resumen$exposicion[match(x, resumen$arbol)])) == 1)
n_misma_sig <- sum(misma_expo & tk_tab$p_aj < 0.05)
tabla_tk <- data.frame(tk_tab$comparacion, fmt(tk_tab$razon, 2),
paste0("[", fmt(tk_tab$li, 2), "; ", fmt(tk_tab$ls, 2), "]"),
fmt_p(tk_tab$p_aj), ifelse(tk_tab$p_aj < 0.05, "sí", "no"))
tabla(tabla_tk, col.names = c("Comparación (B / A)", "Razón B / A", "IC 95 % (Tukey)", "p ajustado", "Difieren (5 %)"),
align = "lrrrr", caption = "Comparaciones de Tukey entre árboles (escala logarítmica, mostradas como razones del área de B respecto a la de A).")
| Comparación (B / A) | Razón B / A | IC 95 % (Tukey) | p ajustado | Difieren (5 %) |
|---|---|---|---|---|
| Mai_L1 / Mai_S1 | 0,70 | [0,55; 0,88] | < 0,001 | sí |
| Mai_L3 / Mai_S1 | 0,71 | [0,56; 0,89] | 0,001 | sí |
| Mai_S2 / Mai_S1 | 0,72 | [0,57; 0,91] | 0,002 | sí |
| Mai_L2 / Mai_L1 | 1,28 | [1,01; 1,62] | 0,037 | sí |
| Mai_L3 / Mai_L2 | 0,79 | [0,63; 1,00] | 0,053 | no |
| Mai_L2 / Mai_S2 | 1,24 | [0,98; 1,57] | 0,088 | no |
| Mai_L2 / Mai_S1 | 0,89 | [0,71; 1,13] | 0,648 | no |
| Mai_L1 / Mai_S2 | 0,97 | [0,77; 1,23] | 0,996 | no |
| Mai_L3 / Mai_S2 | 0,98 | [0,78; 1,24] | 0,999 | no |
| Mai_L3 / Mai_L1 | 1,01 | [0,80; 1,28] | 1,000 | no |
k <- nrow(tk_tab)
par(mar = c(4.5, 9, 3, 1))
plot(tk_tab$razon, seq_len(k), log = "x", pch = 16, yaxt = "n", ylab = "",
xlim = range(c(tk_tab$li, tk_tab$ls)), cex = 1.2,
col = ifelse(tk_tab$p_aj < 0.05, "#D55E00", "grey40"),
xlab = "Razón entre árboles (escala logarítmica)", main = "Comparaciones por pares")
segments(tk_tab$li, seq_len(k), tk_tab$ls, seq_len(k), lwd = 2,
col = ifelse(tk_tab$p_aj < 0.05, "#D55E00", "grey40"))
axis(2, at = seq_len(k), labels = tk_tab$comparacion, las = 1, cex.axis = 0.8)
abline(v = 1, lty = 2)
Razón entre árboles con su intervalo de Tukey. Si el intervalo cruza 1, no hay diferencia detectable.
En 4 de las 10 comparaciones los árboles difieren de forma detectable (en naranja). Cruza esto con la exposición de cada árbol (tabla de la sección 5): 2 de esas diferencias son entre árboles de la misma exposición. Es decir, dos árboles de sol, o dos de sombra, pueden diferir tanto como un árbol de sol y uno de sombra. Esa variación entre árboles es el “ruido” contra el que tiene que competir el efecto de la exposición, y la razón por la que no puede probarse con hojas sueltas.
# Comprobación sin suponer varianzas iguales
ow <- oneway.test(log(area_cm2) ~ id_arbol, data = dg, var.equal = FALSE)
pw <- pairwise.t.test(log(dg$area_cm2), dg$id_arbol, pool.sd = FALSE, p.adjust.method = "holm")$p.value
pw_largo <- data.frame(par = outer(rownames(pw), colnames(pw), paste, sep = " - ")[!is.na(pw)], p_holm = pw[!is.na(pw)])
c(welch_anova_p = ow$p.value)
## welch_anova_p
## 0,0000200676
pw_largo[order(pw_largo$p_holm), ]
## par p_holm
## 4 Mai_L3 - Mai_S1 0,0001499147
## 2 Mai_L1 - Mai_S1 0,0035294705
## 1 Mai_S2 - Mai_S1 0,0047137476
## 10 Mai_L3 - Mai_L2 0,1271846638
## 8 Mai_L2 - Mai_L1 0,1849313115
## 6 Mai_L2 - Mai_S2 0,2529676435
## 3 Mai_L2 - Mai_S1 0,9546299717
## 5 Mai_L1 - Mai_S2 1,0000000000
## 7 Mai_L3 - Mai_S2 1,0000000000
## 9 Mai_L3 - Mai_L1 1,0000000000
El ANOVA de Welch (que no supone varianzas iguales) da p < 0,001; con pares de Welch y ajuste de Holm, 3 pares quedan por debajo del 5 % (con Tukey eran 4). La conclusión general (los árboles no son iguales entre sí) se mantiene, pero los pares concretos que difieren dependen del método cuando un árbol tiene mucha menos variación que los demás; conviene presentar los resultados de Tukey como orientativos.
Ahora con las hojas dentro de árboles y los árboles dentro de una exposición: un diseño anidado. El efecto de la exposición se prueba contra la variación entre árboles, no contra la variación entre hojas.
lim <- dg
lim$arbol <- factor(lim$id_arbol)
m_anid <- lm(log(area_cm2) ~ expo + arbol, data = lim) # arbol esta dentro de expo (cada arbol tiene una sola exposicion)
a_an <- tabla_anova(m_anid)
# Cada fuente se prueba contra el cuadrado medio que corresponde a su unidad
F_expo <- a_an$CM[1] / a_an$CM[2]; p_expo <- pf(F_expo, a_an$gl[1], a_an$gl[2], lower.tail = FALSE)
F_arbol <- a_an$CM[2] / a_an$CM[3]; p_arbol <- pf(F_arbol, a_an$gl[2], a_an$gl[3], lower.tail = FALSE)
tab_an <- data.frame(
Fuente = c("Exposición", "Árbol (dentro de exposición)", "Hojas (residual)"),
gl = a_an$gl, SC = fmt(a_an$SC, 3), CM = fmt(a_an$CM, 3),
Contra = c("CM de árbol", "CM de hojas", ""),
F = c(fmt(F_expo, 2), fmt(F_arbol, 2), ""),
p = c(fmt_p(p_expo), fmt_p(p_arbol), ""))
tabla(tab_an, row.names = FALSE, align = "lrrrlrr",
caption = "ANOVA anidado sobre log(área): hojas dentro de árboles dentro de exposición.")
| Fuente | gl | SC | CM | Contra | F | p |
|---|---|---|---|---|---|---|
| Exposición | 1 | 0,142 | 0,142 | CM de árbol | 0,46 | 0,545 |
| Árbol (dentro de exposición) | 3 | 0,922 | 0,307 | CM de hojas | 8,96 | < 0,001 |
| Hojas (residual) | 45 | 1,543 | 0,034 |
p_ingenuo <- a_an$p[1] # prueba "ingenua": exposición contra el CM de las hojas (la que da lm() por defecto)
anova() hace por defecto)
daría p = 0,048, un resultado que parece significativo
y no lo es: es pseudorreplicación. Si no se hubiera usado el
error correcto, la conclusión habría sido la contraria.Cuánto de la variación se debe al árbol y cuánto a las hojas (componentes de varianza):
n0 <- nrow(lim) / nlevels(lim$arbol) # hojas por árbol (10)
s2_res <- a_an$CM[3]
s2_arb <- max((a_an$CM[2] - a_an$CM[3]) / n0, 0)
pct_arb <- 100 * s2_arb / (s2_arb + s2_res)
r2_arb <- summary(m1)$r.squared
c(var_arbol = s2_arb, var_hoja = s2_res, pct_arbol = pct_arb, r2_arbol = r2_arb)
## var_arbol var_hoja pct_arbol r2_arbol
## 0,02728961 0,03429018 44,31585964 0,40804157
Alrededor del 44 % de la variación (en log) que queda después de quitar la exposición se debe a diferencias entre árboles, y el resto a diferencias entre hojas de un mismo árbol. En total, el árbol explica el 41 % de la variación del log del área. Por eso conviene medir más árboles antes que más hojas por árbol.
La exposición tiene solo 2 niveles. Cuando un factor tiene dos niveles no hay nada que “comparar por pares” después del ANOVA: la prueba F ya es la comparación. Se comprueba con un ANOVA de un factor sobre el log del promedio de cada árbol, donde Tukey da el mismo valor que la F:
medias$log_area <- log(medias$area_cm2)
m_b <- lm(log_area ~ exposicion, data = medias)
a_b <- tabla_anova(m_b)
a_b$Fuente <- c("Exposición", "Error (árboles)")
tabla(formato_anova(a_b), row.names = FALSE, align = "lrrrrr",
caption = "ANOVA de un factor sobre log(promedio por árbol): un dato por árbol.")
| Fuente | gl | SC | CM | F | p |
|---|---|---|---|---|---|
| Exposición | 1 | 0,013 | 0,013 | 0,41 | 0,566 |
| Error (árboles) | 3 | 0,096 | 0,032 |
tk_e <- TukeyHSD(aov(log_area ~ exposicion, data = medias))$exposicion
c(p_Tukey = tk_e[, "p adj"], p_F = a_b$p[1])
## p_Tukey p_F
## 0,5661309 0,5661309
ef <- coef(m_b)[["exposicionsombra"]]
ic_e <- confint(m_b)["exposicionsombra", ]
Los dos p son idénticos (p = 0,566). Los pares de Tukey sirven cuando el factor tiene 3 o más niveles, como en la comparación entre árboles. Razón sombra / sol: 1,11 (IC 95 %: 0,66 a 1,87). El intervalo incluye el 1 (sin diferencia). Como solo hay un grupo, no hay ANOVA de bloques ni efectos simples por grupo (ver el diseño).
Supuestos con un dato por árbol.
par(mfrow = c(1, 2), mar = c(4.5, 4.8, 3, 1))
plot(fitted(m_b), resid(m_b), pch = 21, bg = col_expo[as.character(medias$exposicion)], las = 1, cex = 1.6,
xlab = "Valor ajustado (log del área media)", ylab = "Residuo", main = "Residuos vs ajustados")
abline(h = 0, lty = 2)
qqnorm(resid(m_b), pch = 21, bg = "grey75", las = 1, cex = 1.6, main = "Normalidad de los residuos",
xlab = "Cuantiles teóricos", ylab = "Cuantiles de los residuos")
qqline(resid(m_b))
Diagnóstico del modelo con un dato por árbol: residuos frente a ajustados (izquierda) y gráfico cuantil-cuantil (derecha). Cada punto es un árbol.
shap_b <- shapiro.test(resid(m_b))
shap_b$p.value
## [1] 0,1275698
Con 5 puntos estos gráficos y la prueba de Shapiro-Wilk (p = 0,128) casi no pueden detectar nada: un p grande aquí no demuestra que los supuestos se cumplan, solo que no hay evidencia en contra con tan pocos datos. Por eso la conclusión se apoya también en la prueba de permutación exacta, que no supone normalidad.
resumen_a <- data.frame(
Pregunta = c("¿Difieren los 5 árboles?", "¿Qué árboles difieren?",
"Sol vs sombra, permutación exacta (árbol como unidad)",
"Sol vs sombra, t de Welch (árbol como unidad)",
"Sol vs sombra, ANOVA anidado (árbol como error)",
"Sol vs sombra, ANOVA sobre promedios de árbol",
"Sol vs sombra ignorando el árbol (incorrecto)"),
Herramienta = c("ANOVA de un factor", "Tukey HSD", "Permutación", "Prueba t sobre promedios",
"F anidada", "ANOVA de un factor, un dato por árbol", "ANOVA por defecto / t sobre hojas"),
Resultado = c(paste0("F = ", fmt(a1$F[1], 1), ", p ", fmt_p(a1$p[1])),
paste(sum(tk_tab$p_aj < 0.05), "de", nrow(tk_tab), "pares"),
paste0("p = ", fmt_p(p_perm)),
paste0("p = ", fmt_p(tt$p.value)),
paste0("F = ", fmt(F_expo, 2), ", p = ", fmt_p(p_expo)),
paste0("razón ", fmt(exp(ef), 2), ", p = ", fmt_p(a_b$p[1])),
paste0("p = ", fmt_p(p_ingenuo), " (anova) y ", fmt_p(p_hojas), " (t)")))
tabla(resumen_a, align = "lll")
| Pregunta | Herramienta | Resultado |
|---|---|---|
| ¿Difieren los 5 árboles? | ANOVA de un factor | F = 7,8, p < 0,001 |
| ¿Qué árboles difieren? | Tukey HSD | 4 de 10 pares |
| Sol vs sombra, permutación exacta (árbol como unidad) | Permutación | p = 0,500 |
| Sol vs sombra, t de Welch (árbol como unidad) | Prueba t sobre promedios | p = 0,633 |
| Sol vs sombra, ANOVA anidado (árbol como error) | F anidada | F = 0,46, p = 0,545 |
| Sol vs sombra, ANOVA sobre promedios de árbol | ANOVA de un factor, un dato por árbol | razón 1,11, p = 0,566 |
| Sol vs sombra ignorando el árbol (incorrecto) | ANOVA por defecto / t sobre hojas | p = 0,048 (anova) y 0,115 (t) |
Los caminos que usan el árbol como unidad dan resultados parecidos (p entre 0,500 y 0,633): ninguno encuentra una diferencia entre sol y sombra, sea cual sea la prueba elegida. Lo que cambia la conclusión es ignorar el árbol, que da p más pequeños sin que haya más información.
Qué encontramos. Con el árbol como unidad de análisis, los árboles de sombra tuvieron hojas algo más grandes que los de sol en promedio: 74,5 cm² frente a 66,8 cm² (media de los promedios por árbol; 2 árboles de sombra y 3 de sol). La diferencia es de 7,8 cm² (IC 95 %: -69,9 a 85,5 cm²) o, dicho en razón, una hoja de sombra equivale a 1,11 veces una de sol (IC 95 %: 0,41 a 3,03). Las pruebas dan p entre 0,400 y 0,635. La dirección coincide con la hipótesis, pero la diferencia no se puede distinguir del azar: los datos no permiten ni confirmarla ni descartarla.
Qué tan fuerte es la evidencia. Es débil. Los intervalos son muy anchos (la razón podría estar entre 0,41 y 3,03), y con 2 y 3 árboles el diseño tiene tan poca potencia que ni siquiera el p mínimo posible (0,100 en la permutación) llegaría al 5 %. Que el resultado no sea significativo no significa que no haya efecto: significa que con 5 árboles no se podría haber detectado uno de tamaño moderado.
Lo que añade el ANOVA. El ANOVA entre árboles
muestra que los árboles sí son distintos entre sí
(p < 0,001) y que esa variación es grande: el árbol explica
el 41 % de la variación del log del área. En el ANOVA anidado la
exposición, probada contra el árbol, da p = 0,545; la prueba
ingenua con las hojas como réplicas habría dado p = 0,048. Esa
diferencia entre las dos pruebas es la lección central del análisis: con
el error equivocado se habría “confirmado” la hipótesis; con el
correcto, no. Además, 2 de las diferencias entre árboles que detecta
Tukey son entre árboles de la misma exposición: por
ejemplo, el árbol de sombra Mai_S2 se parece más a los
árboles de sol Mai_L1 y Mai_L3 que al otro
árbol de sombra, Mai_S1.
Posibles explicaciones ambientales (son hipótesis para explicar el patrón, no algo que estos datos demuestren):
Qué más pudo influir (explicaciones alternativas que debes discutir antes de atribuirlo todo a la luz):
Mai_S1 y Mai_L2
(los de hojas más grandes) pertenecen a especies de hoja grande, la
diferencia entre árboles sería de composición de
especies y no de respuesta a la luz. En la plantilla no se
registró la especie.Mai_L3 tiene un
CV de solo 5 %, muy por debajo de los demás. Puede ser un árbol de hoja
de tamaño muy regular o un efecto de cómo se escogieron sus hojas (por
ejemplo, todas de la misma rama). Comprobarlo en campo ayudaría.Mai_L2 y
Mai_L3 se midieron un día después que los otros tres, y la
turgencia de la hoja cambia con las condiciones del día.Resultados frente a hipótesis.
| Es un resultado (lo que los datos muestran) | Es una hipótesis (lo que habría que probar) |
|---|---|
| En estos 5 árboles del campus, los de sombra tuvieron un área media algo mayor que los de sol, pero la diferencia no se distingue del azar. | La causa es una respuesta de la hoja a la luz o a la temperatura. |
| Los 5 árboles difieren entre sí, también entre árboles de la misma exposición. | Los árboles de sombra tienen hojas más grandes que los de sol en el campus en general. |
| Probar la exposición con las hojas como réplicas da un p mucho menor que con el árbol como unidad. | Las especies de sol y de sombra responden igual, en cualquier época del año. |
Mai_L2 y Mai_L3 comparten la
misma coordenada: la ubicación de uno de los dos en el mapa es
aproximada.Mai_L2 y Mai_L3 se midieron un día después que
los otros.R.version.string
## [1] "R version 4.5.2 (2025-10-31 ucrt)"
Sys.Date()
## [1] "2026-11-05"