using TidierData
using DataFrames
using Statistics5 Data wrangling with Tidier
In the original book this is the tidyverse chapter. Julia has a remarkably faithful translation: the Tidier family, a pure-Julia reimplementation of the tidyverse mini-language on top of DataFrames.jl.
5.1 The data
Throughout the book we use a simulated survey of anurans in 180 ponds across three regions. The data are simulated on purpose: because the generating process is known, every model fitted in Chapter 8 and Chapter 9 can be checked against the truth. The generator lives in scripts/make_data.jl.
using CSV
ponds = CSV.read(joinpath("..", "data", "ponds.csv"), DataFrame)
first(ponds, 5)| Row | pond_id | region | hydro | area_m2 | canopy | temp_c | richness | focal | body_size |
|---|---|---|---|---|---|---|---|---|---|
| String7 | String15 | String15 | Float64 | Float64? | Float64 | Int64 | Int64 | Float64 | |
| 1 | P001 | Atlantic | permanent | 174.1 | 70.0 | 22.4 | 8 | 0 | 34.55 |
| 2 | P002 | Amazon | permanent | 161.6 | 42.6 | 23.9 | 12 | 1 | 30.36 |
| 3 | P003 | Cerrado | temporary | 233.9 | 57.5 | 25.2 | 6 | 1 | 34.44 |
| 4 | P004 | Amazon | temporary | 174.5 | 49.4 | 25.6 | 13 | 1 | 39.3 |
| 5 | P005 | Cerrado | permanent | 314.4 | 30.0 | 27.6 | 6 | 0 | 43.65 |
The book is rendered from inside the en/ folder, hence the ".." (the folder above) in this and the following chapters. In your own session, with the book folder open, use joinpath("data", "ponds.csv"), as in the exercise box below.
describe(ponds, :eltype, :nmissing, :mean, :min, :max)| Row | variable | eltype | nmissing | mean | min | max |
|---|---|---|---|---|---|---|
| Symbol | Type | Int64 | Union… | Any | Any | |
| 1 | pond_id | String7 | 0 | P001 | P180 | |
| 2 | region | String15 | 0 | Amazon | Cerrado | |
| 3 | hydro | String15 | 0 | permanent | temporary | |
| 4 | area_m2 | Float64 | 0 | 240.215 | 10.0 | 1981.8 |
| 5 | canopy | Union{Missing, Float64} | 7 | 49.1422 | 6.4 | 95.8 |
| 6 | temp_c | Float64 | 0 | 24.4406 | 17.0 | 33.9 |
| 7 | richness | Int64 | 0 | 7.13333 | 0 | 31 |
| 8 | focal | Int64 | 0 | 0.45 | 0 | 1 |
| 9 | body_size | Float64 | 0 | 35.6777 | 26.91 | 44.26 |
| Column | Meaning |
|---|---|
pond_id |
pond identifier |
region |
region (Atlantic, Cerrado, Amazon) |
hydro |
hydroperiod (permanent, temporary) |
area_m2 |
pond area (m²) |
canopy |
canopy cover (%) |
temp_c |
water temperature (°C) |
richness |
species richness (count) |
focal |
focal species occurrence (0/1) |
body_size |
mean body size (mm) |
The Your turn boxes work as in Chapter 4. Here the answers are tables and numbers computed from the pond data, so start each session by loading the checker, the packages and the data, with the book folder open in VS Code:
include("exercises/check.jl")
using TidierData, DataFrames, Statistics, CSV
ponds = CSV.read(joinpath("data", "ponds.csv"), DataFrame)Column names are in English in both versions of the book, so that the code is literally identical in English and Portuguese.
5.2 @chain: the pipe
@chain is the backbone of everything that follows. It passes each line’s result as the first argument of the next — exactly the role of |> (or %>%) in R.
@chain ponds begin
@filter(region == "Amazon")
@select(pond_id, area_m2, canopy, richness)
@arrange(desc(richness))
first(5)
end| Row | pond_id | area_m2 | canopy | richness |
|---|---|---|---|---|
| String7 | Float64 | Float64? | Int64 | |
| 1 | P065 | 1048.6 | 18.0 | 31 |
| 2 | P044 | 568.1 | 31.7 | 24 |
| 3 | P067 | 411.4 | 30.9 | 21 |
| 4 | P085 | 204.6 | 58.2 | 19 |
| 5 | P073 | 140.5 | 17.7 | 18 |
In R
ponds %>%
filter(region == "Amazon") %>%
select(pond_id, area_m2, canopy, richness) %>%
arrange(desc(richness)) %>%
head(5)The correspondence is line for line. The only syntactic difference is the @ and the begin ... end.
Write a @chain that returns the three largest ponds (by area_m2) in the Cerrado, largest first. Store the result in top3 and check it with check("5.1", top3).
Three steps, one per line: keep the Cerrado rows, sort by area from largest to smallest, keep the first three. The block above does something very similar.
top3 = @chain ponds begin
@filter(region == "Cerrado")
@arrange(desc(area_m2))
first(3)
end| Row | pond_id | region | hydro | area_m2 | canopy | temp_c | richness | focal | body_size |
|---|---|---|---|---|---|---|---|---|---|
| String7 | String15 | String15 | Float64 | Float64? | Float64 | Int64 | Int64 | Float64 | |
| 1 | P130 | Cerrado | temporary | 1981.8 | 84.9 | 26.0 | 9 | 0 | 38.53 |
| 2 | P034 | Cerrado | temporary | 1126.3 | missing | 22.9 | 6 | 0 | 33.53 |
| 3 | P143 | Cerrado | temporary | 932.7 | 39.8 | 25.1 | 11 | 0 | 34.15 |
check("5.1", top3)✓ Correct!
5.3 The five verbs
5.3.1 @filter — pick rows
@chain ponds begin
@filter(richness > 8 && hydro == "permanent")
nrow
end39
Note: inside @filter, use && and ||, not & and | as in dplyr.
How many temporary ponds have canopy cover above 60% and richness below the overall median richness? Check the number with check("5.2", n).
Compute the median first, outside the chain: med = median(ponds.richness). Inside a Tidier macro a bare name means a column, so to use the variable med write !!med.
If you get TypeError: non-boolean (Missing) used in boolean context, some ponds have no canopy value, and missing > 60 is neither true nor false. Remove those rows first with @drop_missing(canopy) (Section 5.4).
med = median(ponds.richness)
n = @chain ponds begin
@drop_missing(canopy)
@filter(hydro == "temporary" && canopy > 60 && richness < !!med)
nrow
end18
check("5.2", n)✓ Correct!
Dropping ponds without canopy is the right call here: we cannot know whether their canopy is above 60%. The median, though, is computed on all 180 ponds, before dropping anything.
5.3.2 @select — pick columns
@chain ponds begin
@select(pond_id, region, richness)
first(3)
end| Row | pond_id | region | richness |
|---|---|---|---|
| String7 | String15 | Int64 | |
| 1 | P001 | Atlantic | 8 |
| 2 | P002 | Amazon | 12 |
| 3 | P003 | Cerrado | 6 |
# exclusion and ranges work as in dplyr
@chain ponds begin
@select(-pond_id, -focal)
names
end9-element Vector{String}:
"region"
"hydro"
"area_m2"
"canopy"
"temp_c"
"richness"
"focal"
"body_size"
"pond_id"
5.3.3 @mutate — create columns
ponds_t = @chain ponds begin
@mutate(
log_area = log(area_m2),
canopy_std = (canopy - mean(skipmissing(canopy))) / std(skipmissing(canopy)),
open = canopy < 40,
)
end
@chain ponds_t begin
@select(pond_id, area_m2, log_area, canopy, canopy_std, open)
first(5)
end| Row | pond_id | area_m2 | log_area | canopy | canopy_std | open |
|---|---|---|---|---|---|---|
| String7 | Float64 | Float64 | Float64? | Float64? | Bool? | |
| 1 | P001 | 174.1 | 5.15963 | 70.0 | 0.946503 | false |
| 2 | P002 | 161.6 | 5.08512 | 42.6 | -0.296877 | false |
| 3 | P003 | 233.9 | 5.45489 | 57.5 | 0.379268 | false |
| 4 | P004 | 174.5 | 5.16192 | 49.4 | 0.0116988 | false |
| 5 | P005 | 314.4 | 5.75067 | 30.0 | -0.868651 | true |
Bigger ponds tend to hold more species. Add a column rich_per_100m2 with the number of species per 100 m² of pond, keeping every row and column of ponds. Store the result in ponds_d and check it with check("5.3", ponds_d).
richness / area_m2 is species per m²; multiply by 100.
ponds_d = @chain ponds begin
@mutate(rich_per_100m2 = richness / area_m2 * 100)
end
check("5.3", ponds_d)✓ Correct!
5.3.4 @group_by + @summarize — aggregate
@chain ponds begin
@group_by(region, hydro)
@summarize(
n = n(),
mean_rich = mean(richness),
sd_rich = std(richness),
prop_focal = mean(focal),
)
@arrange(region, hydro)
endGroupedDataFrame with 3 groups based on key: region
| Row | region | hydro | n | mean_rich | sd_rich | prop_focal |
|---|---|---|---|---|---|---|
| String15 | String15 | Int64 | Float64 | Float64 | Float64 | |
| 1 | Amazon | permanent | 26 | 11.2692 | 5.99038 | 0.538462 |
| 2 | Amazon | temporary | 22 | 10.9091 | 5.95946 | 0.318182 |
⋮
| Row | region | hydro | n | mean_rich | sd_rich | prop_focal |
|---|---|---|---|---|---|---|
| String15 | String15 | Int64 | Float64 | Float64 | Float64 | |
| 1 | Cerrado | permanent | 31 | 4.77419 | 2.90643 | 0.580645 |
| 2 | Cerrado | temporary | 35 | 4.8 | 2.95854 | 0.4 |
Compute, for each region, the proportion of ponds where the focal species occurs, in a column called prop_focal, sorted from highest to lowest. Check it with check("5.4", by_region).
focal is 0 or 1, so its mean is the proportion of ponds where it is 1. Sorting from highest to lowest is @arrange(desc(...)).
by_region = @chain ponds begin
@group_by(region)
@summarize(prop_focal = mean(focal))
@arrange(desc(prop_focal))
end
check("5.4", by_region)✓ Correct!
5.3.5 @arrange — sort
@chain ponds begin
@arrange(region, desc(richness))
@select(region, pond_id, richness)
first(6)
end| Row | region | pond_id | richness |
|---|---|---|---|
| String15 | String7 | Int64 | |
| 1 | Amazon | P065 | 31 |
| 2 | Amazon | P044 | 24 |
| 3 | Amazon | P067 | 21 |
| 4 | Amazon | P085 | 19 |
| 5 | Amazon | P073 | 18 |
| 6 | Amazon | P164 | 18 |
5.4 Missing data
The canopy column has missing values on purpose.
sum(ismissing.(ponds.canopy))7
@chain ponds begin
@drop_missing(canopy)
nrow
end173
# aggregating while ignoring missings
@chain ponds begin
@group_by(region)
@summarize(mean_canopy = mean(skipmissing(canopy)))
end| Row | region | mean_canopy |
|---|---|---|
| String15 | Float64 | |
| 1 | Atlantic | 51.7125 |
| 2 | Amazon | 45.313 |
| 3 | Cerrado | 49.327 |
mean(canopy) returns missing if any value is missing — the same behaviour as R’s mean() without na.rm = TRUE. Be explicit with skipmissing.
Before dropping missing values, it is worth knowing where they are: if they concentrate in one region, dropping them changes that region more than the others. Count, per region, how many ponds have no canopy value, in a column called n_missing. Check it with check("5.5", gaps).
ismissing.(canopy) gives true for each missing value, and sum counts the trues. Both work inside @summarize.
gaps = @chain ponds begin
@group_by(region)
@summarize(n_missing = sum(ismissing.(canopy)))
end
check("5.5", gaps)✓ Correct!
Seven missing values spread over three regions: no region is hit much harder than the others, so dropping them is unlikely to bias comparisons between regions.
5.5 Long and wide formats
long = @chain ponds begin
@select(pond_id, region, area_m2, canopy, temp_c)
@pivot_longer(area_m2:temp_c, names_to = "variable", values_to = "value")
end
first(long, 6)| Row | pond_id | region | variable | value |
|---|---|---|---|---|
| String7 | String15 | String | Float64? | |
| 1 | P001 | Atlantic | area_m2 | 174.1 |
| 2 | P002 | Amazon | area_m2 | 161.6 |
| 3 | P003 | Cerrado | area_m2 | 233.9 |
| 4 | P004 | Amazon | area_m2 | 174.5 |
| 5 | P005 | Cerrado | area_m2 | 314.4 |
| 6 | P006 | Cerrado | area_m2 | 620.2 |
@chain long begin
@pivot_wider(names_from = variable, values_from = value)
first(3)
end| Row | pond_id | region | area_m2 | canopy | temp_c |
|---|---|---|---|---|---|
| String7 | String15 | Float64? | Float64? | Float64? | |
| 1 | P001 | Atlantic | 174.1 | 70.0 | 22.4 |
| 2 | P002 | Amazon | 161.6 | 42.6 | 23.9 |
| 3 | P003 | Cerrado | 233.9 | 57.5 | 25.2 |
5.6 Joins
# an auxiliary table with region-level information
regions = DataFrame(
region = ["Atlantic", "Cerrado", "Amazon"],
biome_type = ["forest", "savanna", "forest"],
mean_rain = [1400, 1500, 2300],
)
@chain ponds begin
@left_join(regions, region)
@select(pond_id, region, biome_type, mean_rain, richness)
first(5)
end| Row | pond_id | region | biome_type | mean_rain | richness |
|---|---|---|---|---|---|
| String7 | String15 | String? | Int64? | Int64 | |
| 1 | P001 | Atlantic | forest | 1400 | 8 |
| 2 | P002 | Amazon | forest | 2300 | 12 |
| 3 | P003 | Cerrado | savanna | 1500 | 6 |
| 4 | P004 | Amazon | forest | 2300 | 13 |
| 5 | P005 | Cerrado | savanna | 1500 | 6 |
Using the regions table above, compute the mean richness of forest and savanna ponds, in a column called mean_rich. Check it with check("5.6", by_biome).
Join first, so every pond gets its biome_type; then group by biome_type and summarize.
by_biome = @chain ponds begin
@left_join(regions, region)
@group_by(biome_type)
@summarize(mean_rich = mean(richness))
end
check("5.6", by_biome)✓ Correct!
5.7 DataFrames.jl underneath
Tidier is a convenience layer. It is worth knowing the native form, which is what you will meet in most of the ecosystem’s documentation:
combine(
groupby(ponds, :region),
:richness => mean => :mean_rich,
:richness => length => :n,
)| Row | region | mean_rich | n |
|---|---|---|---|
| String15 | Float64 | Int64 | |
| 1 | Atlantic | 6.59091 | 66 |
| 2 | Amazon | 11.1042 | 48 |
| 3 | Cerrado | 4.78788 | 66 |
In Tidier, this would be @group_by(region) followed by @summarize(mean_rich = mean(richness), n = n()): the same idea as the @group_by/@summarize above, but grouped by region only. :region is a Symbol, Julia’s way of naming a column, and :richness => mean => :mean_rich reads “take richness, apply mean and call the result mean_rich”. Use Tidier to write, and recognize the native form to read.
Rewrite your answer to 5.4 with groupby, combine and sort from DataFrames.jl, without Tidier macros. Check it with check("5.7", by_region_native).
sort(df, :col, rev = true) sorts from highest to lowest.
by_region_native = sort(
combine(groupby(ponds, :region), :focal => mean => :prop_focal),
:prop_focal, rev = true)
check("5.7", by_region_native)✓ Correct!
Challenges
These have no checker. They are about what the numbers do — and do not — tell you.
- In 5.4 the Cerrado comes first, but the three proportions only range from 0.42 to 0.48, each from 48 to 66 ponds. Open
scripts/make_data.jl: does region affect the focal species in the simulation? What does that tell you about reading a ranking off a summary table? - In 5.6 forest ponds are richer than savanna ponds. But “forest” here means Atlantic plus Amazon. Compute mean richness per region and decide whether “forest” is a useful grouping for this question.
- Use
@pivot_longerto putarea_m2,canopyandtemp_cin one column, then compute the mean of each variable per region in a single pipeline. How do you deal with the missing canopy values?