---
title: "From a field sheet to your first analysis"
description: "A worked R pipeline for ecologists: read a messy field sheet, clean species names, reshape and join, then compute diversity, letting cleaning decide the result."
date: 2026-07-20 10:00
categories: [R, dplyr, tidyr, ecology tutorial]
image: thumbnail.png
image-alt: "Grouped bar chart of Shannon diversity for four sites under a naive and a clean pipeline, where the tallest bar moves from site S3 to site S1."
---
The earlier tutorials in this series each fixed one problem: a blank cell is not a zero, a species table can be wide or long, name variants inflate richness, and an inner join drops absent sites. This post runs the whole pipeline end to end on one messy field sheet, so you can see how the pieces connect and, more importantly, how the cleaning steps decide the answer. We finish with a first diversity analysis and hand off to the diversity tutorial for what the numbers mean.
The measurable point of a worked example is that the decisions compound. Skipping the name cleaning and using the wrong join does not just shift a number: it changes which site you would call the most diverse.
## The raw sheet
Field sheets are typed by people, so they are messy. Here is one as it might come out of a spreadsheet: long format, one row per record, with the species names entered inconsistently (mixed case and a stray double space) because different people wrote them on different days:
```{r}
#| label: read-raw
#| message: false
library(dplyr)
library(tidyr)
raw_csv <- "site,species,count
S1,Carex flava,20
S1,Carex flacca,8
S1,Juncus effusus,5
S1,Poa pratensis,3
S2,Carex flava,15
S2,Juncus effusus,20
S2,Festuca rubra,10
S3,Carex flava,10
S3,carex flava,10
S3,Carex flava,10
S3,Poa pratensis,10"
raw <- read.csv(text = raw_csv, stringsAsFactors = FALSE)
raw
```
We also have a **survey frame**: the list of every site that was visited. Four sites were surveyed, and site S4 turned up no plants at all, so it never appears in the observations:
```{r}
#| label: survey-frame
sites <- data.frame(
site = paste0("S", 1:4),
habitat = c("fen", "fen", "meadow", "meadow"),
stringsAsFactors = FALSE
)
sites
```
Look at S3. The three rows `Carex flava`, `carex flava` and `Carex flava` are the same species written three ways. Uncorrected, R treats them as three species. That is the trap the pipeline has to close.
## A helper for the diversity index
We will compute the Shannon index, which needs relative abundances. This one-line helper takes a vector of counts and returns Shannon diversity (it drops zeros, since `0 * log(0)` is defined as zero):
```{r}
#| label: shannon-fn
shannon <- function(x) {
p <- x[x > 0]
p <- p / sum(p)
-sum(p * log(p))
}
```
## The naive pipeline
First, the pipeline a hurried analyst writes: sum counts per site and species, pivot to a wide site-by-species matrix, and join the survey frame with an inner join. No name cleaning:
```{r}
#| label: naive-pipeline
naive_wide <- raw |>
group_by(site, species) |>
summarise(count = sum(count), .groups = "drop") |>
pivot_wider(names_from = species, values_from = count, values_fill = 0)
naive <- naive_wide |>
rowwise() |>
mutate(richness = sum(c_across(-site) > 0),
shannon = shannon(c_across(-c(site, richness)))) |>
ungroup() |>
select(site, richness, shannon)
# inner join with the survey frame: S4 has no observations, so it is dropped
naive_out <- inner_join(sites, naive, by = "site")
naive_out |> mutate(shannon = round(shannon, 3))
```
```{r}
#| label: naive-numbers
#| include: false
naive_top <- naive_out$site[which.max(naive_out$shannon)]
naive_meanR <- mean(naive_out$richness)
naive_nsite <- nrow(naive_out)
naive_s3 <- naive_out$shannon[naive_out$site == "S3"]
naive_s3R <- naive_out$richness[naive_out$site == "S3"]
```
By this account the most diverse site is `r naive_top`, with a Shannon value of `r sprintf("%.3f", naive_s3)` and a richness of `r naive_s3R`. That is entirely an artefact: S3's three spellings of *Carex flava* became three equally abundant species, which is the most even four-species community possible.
## The clean pipeline
Now the careful version. We normalise the names first (trim whitespace, collapse internal spaces, lower-case), map them through a small lookup so every variant resolves to one accepted name, and check that nothing fell through. Then the same reshape, but with a **left join** so the surveyed-but-empty S4 is kept and its missing counts become zeros:
```{r}
#| label: clean-pipeline
normalise <- function(x) tolower(gsub("\\s+", " ", trimws(x)))
lookup <- c("carex flava" = "Carex flava",
"carex flacca" = "Carex flacca",
"juncus effusus" = "Juncus effusus",
"poa pratensis" = "Poa pratensis",
"festuca rubra" = "Festuca rubra")
raw_clean <- raw |> mutate(species = lookup[normalise(species)])
stopifnot(!anyNA(raw_clean$species)) # every raw string matched the lookup
clean_wide <- raw_clean |>
group_by(site, species) |>
summarise(count = sum(count), .groups = "drop") |>
pivot_wider(names_from = species, values_from = count, values_fill = 0)
clean <- clean_wide |>
rowwise() |>
mutate(richness = sum(c_across(-site) > 0),
shannon = shannon(c_across(-c(site, richness)))) |>
ungroup() |>
select(site, richness, shannon)
# left join keeps S4 (surveyed, nothing found): richness 0, Shannon 0
clean_out <- left_join(sites, clean, by = "site") |>
mutate(richness = replace_na(richness, 0L),
shannon = replace_na(shannon, 0))
clean_out |> mutate(shannon = round(shannon, 3))
```
```{r}
#| label: clean-numbers
#| include: false
clean_top <- clean_out$site[which.max(clean_out$shannon)]
clean_meanR <- mean(clean_out$richness)
clean_nsite <- nrow(clean_out)
clean_s3 <- clean_out$shannon[clean_out$site == "S3"]
clean_s3R <- clean_out$richness[clean_out$site == "S3"]
```
Two things move. S3's richness falls from `r naive_s3R` to `r clean_s3R` and its Shannon value from `r sprintf("%.3f", naive_s3)` to `r sprintf("%.3f", clean_s3)`, once its three fake species collapse into one real *Carex flava*. And S4 reappears as a surveyed site with zero plants. The most diverse site is now `r clean_top`, not `r naive_top`.
## The decisions compounded
Put the two pipelines side by side and the effect is not subtle:
```{r}
#| label: fig-compare
#| echo: false
#| fig-width: 6.8
#| fig-height: 3.9
#| fig-cap: "Shannon diversity by site under the naive pipeline (no name cleaning, inner join) and the clean pipeline. Uncorrected name variants make S3 look like the most diverse site; cleaning them collapses S3 and puts S1 on top, and the left join restores the surveyed-but-empty S4."
#| fig-alt: "Grouped bar chart of Shannon diversity for sites S1 to S4 under naive and clean pipelines. Under naive, S3 is tallest near 1.39 and S4 is absent; under clean, S1 is tallest near 1.14, S3 drops to about 0.56, and S4 appears at zero."
library(ggplot2)
te_green <- "#275139"
te_grey <- "#9aa79a"
cmp <- bind_rows(
naive_out |> select(site, shannon) |> mutate(pipeline = "naive"),
clean_out |> select(site, shannon) |> mutate(pipeline = "clean")
)
cmp$pipeline <- factor(cmp$pipeline, levels = c("naive", "clean"),
labels = c("naive (no name clean, inner join)", "clean (names + left join)"))
ggplot(cmp, aes(x = site, y = shannon, fill = pipeline)) +
geom_col(position = position_dodge2(preserve = "single"), width = 0.7) +
scale_fill_manual(values = c(te_grey, te_green)) +
scale_y_continuous(expand = expansion(mult = c(0, 0.06))) +
labs(x = NULL, y = "Shannon diversity", fill = NULL) +
theme_minimal(base_size = 13) +
theme(plot.background = element_rect(fill = "white", colour = NA),
panel.background = element_rect(fill = "white", colour = NA),
panel.grid.minor = element_blank(),
panel.grid.major.x = element_blank(),
legend.position = "top",
axis.title = element_text(colour = "grey25"))
```
The mean richness across sites tells the same story from the summary side: the naive pipeline reports `r sprintf("%.2f", naive_meanR)` species per site over `r naive_nsite` sites, while the clean pipeline reports `r sprintf("%.2f", clean_meanR)` over `r clean_nsite` sites. Both the inflation (name variants adding species) and the deflation (the dropped empty site) come from cleaning decisions, not from the plants.
## Where to go next
You now have a clean site-by-species matrix and a first diversity number per site. What Shannon actually measures, how it trades off richness against evenness, and when a different index is a better choice, is the subject of the alpha-diversity tutorial. Everything downstream, ordination, models, maps, starts from the same clean table you just built.
## Related tutorials
- [Reading field data into R](../reading-field-data-into-r/)
- [Cleaning species names before you count](../cleaning-species-names/)
- [Joining ecological tables without losing zeros](../joining-ecological-tables/)
- [Alpha diversity indices in R](../diversity-indices-in-r/)
## References
Wickham H 2014. Journal of Statistical Software 59(10):1 (10.18637/jss.v059.i10).
Gotelli NJ, Colwell RK 2001. Ecology Letters 4(4):379 (10.1046/j.1461-0248.2001.00230.x).