tidyverse intro

Unlike in your coursework, real world datasets are often messy. Data often comes from several sources, was collected by different people, entered into a spreadsheet by even different people, and arrives on your computer in multiple arrangements. There may be missing or incorrect values, multiple labels for the same treatment, and many unnecessary columns.

Even with clean datasets, you should spend a good amount of time looking at your data in different ways. This is called Exploratory Data Analysis (EDA). Quick plots and summaries are a great way to identify potential errors and can help you develop an intuitive sense of complex datasets.

The tidyverse is “an opinionated collection of R packages designed for data science” that is commonly used in the R-based data science world. The packages share a common philosophy with a distinct coding grammar and data structures. They work collaboratively to make data science easier, cleaner, and more reproducible.

This is a brief overview – there is much, much more that we will not cover.

What makes a dataset tidy vs. messy?

Within the tidyverse framework, a tidy dataset is one where:

  1. Each variable is a single column
  2. Each observation is a single row
  3. Each type of observational unit is a single table

If you have multiple related spreadsheets, each should have a column with the same name that allows them to be joined or merged appropriately.

Guidelines for data entry

When entering your data, try to keep to the following guidelines:

  1. Be consistent. Use a single way of entering a variable (Y/N or Yes/No or 0/1).
  2. Choose good names. Use underscores, camelCase, or SnakeCase instead of spaces. Use short, descriptive names. In R, object names can’t start with numbers or special characters, so try to avoid that in your factor levels as well.
  3. Write dates as YYYY-MM-DD. This is the global ISO 8601 standard. It sorts well and might keep Excel from mangling your data.
  4. No empty cells. Enter NA instead.
  5. Put just one thing in a cell. Have a column weight_kg filled with numbers rather than a column called weight filled with values like “5.5 kg”.
  6. Don’t use font color or highlighting as data. Sometimes used to flag values. Instead, create a new column to store the information.
  7. Save data as plain text files. Proprietary formats like .xlsx can be handy, but plain text (.csv, .txt, .tsv, etc) does not require special software and can be read by anyone on any machine.

Key packages in the tidyverse

There are many packages associated with the tidyverse. We will cover aspects of only a select few:

  • tibble: a re-imagining of the dataframe
  • dplyr: data manipulation
  • tidyr: tidying data
  • ggplot2: visualization
  • stringr: working with strings
  • lubridate: working with dates and date-times

When you install the tidyverse package, all of these are installed (and more).

# Not needed if you did this once already
install.packages("tidyverse")
# Needed each time you restart R 
library(tidyverse)

Review: Piping

As seen in Practical 1, piping helps keep your code legible. The ‘pipe operator’ allows you to string together functions by taking the output of one and sending it as input to the next. Many functions in the tidyverse are designed to work nicely in pipes.

The native R pipe is |> and was introduced in R 4.1.0. Prior to this, piping required the %>% function in the magrittr package, which you may see in help pages or online documentation.

iris$Sepal.Length |>
  sort() |>
  head(n=4)

The convenience will become clear as we go.


Tibbles

Tibbles are dataframes, but with a few extra tweaks. See the Tibbles chapter in R for Data Science for a more comprehensive overview.

str(msleep)
tibble [83 × 11] (S3: tbl_df/tbl/data.frame)
 $ name        : chr [1:83] "Cheetah" "Owl monkey" "Mountain beaver" "Greater short-tailed shrew" ...
 $ genus       : chr [1:83] "Acinonyx" "Aotus" "Aplodontia" "Blarina" ...
 $ vore        : chr [1:83] "carni" "omni" "herbi" "omni" ...
 $ order       : chr [1:83] "Carnivora" "Primates" "Rodentia" "Soricomorpha" ...
 $ conservation: chr [1:83] "lc" NA "nt" "lc" ...
 $ sleep_total : num [1:83] 12.1 17 14.4 14.9 4 14.4 8.7 7 10.1 3 ...
 $ sleep_rem   : num [1:83] NA 1.8 2.4 2.3 0.7 2.2 1.4 NA 2.9 NA ...
 $ sleep_cycle : num [1:83] NA NA NA 0.133 0.667 ...
 $ awake       : num [1:83] 11.9 7 9.6 9.1 20 9.6 15.3 17 13.9 21 ...
 $ brainwt     : num [1:83] NA 0.0155 NA 0.00029 0.423 NA NA NA 0.07 0.0982 ...
 $ bodywt      : num [1:83] 50 0.48 1.35 0.019 600 ...

Munging data

It is best to keep your data file in its original form and do all cleaning, filtering, transforming, etc. in R for full transparency and reproducibility. Editing by hand leaves no trail of what you did; mistakes are easy to make but difficult to find.

The dplyr package provides functions for munging data. See the Data Transformation chapter in R for Data Science for a more comprehensive overview.

Key functions:

  • filter() filters rows based on a TRUE/FALSE logical statement
  • select() selects columns by name, number, or a tidyselect helper function
  • arrange() reorders the tibble according to the column(s) provided
  • mutate() creates new columns
  • group_by() groups the tibble by the column(s) provided
  • summarise() creates summaries, either for the whole tibble or by group

filter()

Use filter() to subset data based on logical statements.

msleep |>
  filter(vore=="herbi")
msleep |> 
  filter(vore=="herbi" & !is.na(sleep_rem))

select()

Use select() to select (or exclude) particular columns. There are many selection helpers available, such as starts_with() or ends_with(). Use a negative sign to exclude specified columns.

msleep |>
  select(name, order, starts_with("sleep"))
msleep |>
  select(-conservation)

arrange()

Use arrange() to reorder your data by one or more columns.

msleep |>
  filter(vore=="herbi") |>
  arrange(bodywt)

mutate()

Use mutate() to create new columns. If you use group_by() first, calculations will be for each group.

msleep |>
  filter(!is.na(brainwt) & !is.na(bodywt)) |>
  mutate(brain_pct=brainwt/bodywt*100) |>
  arrange(desc(brain_pct))
msleep |>
  select(name, vore, awake) |>
  mutate(vs_grandMean=awake - mean(awake)) |>
  group_by(vore) |>
  mutate(vs_voreMean=awake - mean(awake)) 

summarise()

Use summarise() to create new columns that summarise your data. If you use group_by() first, the summaries will be calculated for each group.

msleep |>
  filter(!is.na(awake)) |>
  summarise(nSpp=n_distinct(name),
            nGenera=n_distinct(genus),
            awake_mean=mean(awake),
            awake_sd=sd(awake))
msleep |>
  filter(!is.na(awake)) |>
  group_by(vore) |>
  summarise(nSpp=n_distinct(name),
            nGenera=n_distinct(genus),
            awake_mean=mean(awake),
            awake_sd=sd(awake))

With the msleep dataset, remove all rows that do not have a conservation status and sort the rows so that they are in alphabetical order by 1) order and 2) genus.

Code (bonus)
msleep |>
  filter(!is.na(conservation)) |>
  arrange(order, genus)
# A tibble: 54 × 11
   name   genus vore  order conservation sleep_total sleep_rem sleep_cycle awake
   <chr>  <chr> <chr> <chr> <chr>              <dbl>     <dbl>       <dbl> <dbl>
 1 Cow    Bos   herbi Arti… domesticated         4         0.7       0.667  20  
 2 Roe d… Capr… herbi Arti… lc                   3        NA        NA      21  
 3 Goat   Capri herbi Arti… lc                   5.3       0.6      NA      18.7
 4 Giraf… Gira… herbi Arti… cd                   1.9       0.4      NA      22.1
 5 Sheep  Ovis  herbi Arti… domesticated         3.8       0.6      NA      20.2
 6 Pig    Sus   omni  Arti… domesticated         9.1       2.4       0.5    14.9
 7 Cheet… Acin… carni Carn… lc                  12.1      NA        NA      11.9
 8 North… Call… carni Carn… vu                   8.7       1.4       0.383  15.3
 9 Dog    Canis carni Carn… domesticated        10.1       2.9       0.333  13.9
10 Domes… Felis carni Carn… domesticated        12.5       3.2       0.417  11.5
# ℹ 44 more rows
# ℹ 2 more variables: brainwt <dbl>, bodywt <dbl>

In the msleep dataset, how many carnivores sleep more than 8 hours? What proportion of carnivore species is this?

Code (bonus)
msleep |>
  filter(vore=="carni") |>
  summarise(n_gt8=sum(sleep_total > 8),
            prop_gt8=mean(sleep_total > 8)) # or sum(...)/n()
# A tibble: 1 × 2
  n_gt8 prop_gt8
  <int>    <dbl>
1    13    0.684

Reshaping data

The tidyr package has many functions for tidying your datasets. We will cover just two that tend to be particularly useful.

Key functions:

pivot_longer()

Use pivot_longer() to stack columns (cols=), specifying new columns to store the original column values (values_to=) and original column names (names_to=).

msleep |>
  select(name, brainwt, bodywt) |>
  pivot_longer(cols=c("brainwt", "bodywt"), 
               names_to="structure", 
               values_to="weight")

pivot_wider()

Use pivot_wider() to spread the values from single column (values_from=) into several based on a categorical column (names_from=).

iris |>
  group_by(Species) |>
  mutate(Plant_id=row_number()) |>
  ungroup() |>
  select(Plant_id, Species, Petal.Width) |>
  pivot_wider(names_from="Species", values_from="Petal.Width")

With the iris dataset, select the columns for species, petal width, and sepal width, then transform it to be longer with columns Species, FlowerPart, and Width.

Code (bonus)
iris |>
  select(Species, Petal.Width, Sepal.Width) |>
  pivot_longer(c("Petal.Width", "Sepal.Width"), 
               names_to="FlowerPart", 
               values_to="Width")
# A tibble: 300 × 3
   Species FlowerPart  Width
   <fct>   <chr>       <dbl>
 1 setosa  Petal.Width   0.2
 2 setosa  Sepal.Width   3.5
 3 setosa  Petal.Width   0.2
 4 setosa  Sepal.Width   3  
 5 setosa  Petal.Width   0.2
 6 setosa  Sepal.Width   3.2
 7 setosa  Petal.Width   0.2
 8 setosa  Sepal.Width   3.1
 9 setosa  Petal.Width   0.2
10 setosa  Sepal.Width   3.6
# ℹ 290 more rows

With the msleep dataset, calculate the mean awake time for each feeding type (vore) and then transform it so that there is a single row and a column for each feeding type.

Code (bonus)
msleep |>
  group_by(vore) |>
  summarise(mean=mean(awake)) |>
  pivot_wider(names_from="vore", values_from="mean")
# A tibble: 1 × 5
  carni herbi insecti  omni  `NA`
  <dbl> <dbl>   <dbl> <dbl> <dbl>
1  13.6  14.5    9.06  13.1  13.8

ggplot2

The ggplot2 package is fantastic for exploratory data analysis, as well as creating polished plots for final presentation. See the ‘reference’ tab on the package website for all plotting functions, and the Exploratory Data Analysis chapter and the Graphics for Communication chapter in R for Data Science for many examples and much more information.

Key functions:

  • ggplot() initializes a plot object; always used first, followed by +
  • geom_*() plot elements (e.g., geom_point(), geom_line(), etc)
  • geom_smooth() calculates and plots a trend line
  • scale_colour_*() specifies a custom colour palette
  • scale_fill_*() specifies a custom fill palette
  • scale_x/y_*() specifies a custom x or y axis
  • labs() adds labels (x, y, title, subtitle)
  • facet_wrap(), facet_grid() partition data into panels by the specified variable(s)

Scatter plots

# scatter plot
msleep |>
  ggplot(aes(brainwt, bodywt)) + 
  geom_point()

msleep |>
  ggplot(aes(brainwt, bodywt)) + 
  geom_point() + 
  scale_x_log10("Brain weight (kg)") + 
  scale_y_log10("Body weight (kg)")

Histograms, etc

# histogram
msleep |> 
  ggplot(aes(sleep_cycle)) + 
  geom_histogram()

# density plot
msleep |> 
  ggplot(aes(sleep_cycle)) + 
  geom_density()

# box plot
msleep |>
  ggplot(aes(vore, sleep_cycle)) + 
  geom_boxplot()

# bar plot
msleep |>
  ggplot(aes(vore)) + 
  geom_bar()

msleep |>
  filter(!is.na(vore)) |>
  mutate(vore=paste0(vore, "vore")) |>
  ggplot(aes(vore)) +
  geom_bar()

Trend lines

# scatter plot with group-level trend lines
iris |>
  ggplot(aes(Sepal.Length, Petal.Length, colour=Species)) + 
  geom_point() +
  geom_smooth(method="lm")

# scatter plot with global trend line
iris |>
  ggplot(aes(Sepal.Length, Petal.Length)) + 
  geom_point(aes(colour=Species)) +
  geom_smooth(method="lm")

# scatter plot with global and group-level trend lines
iris |>
  ggplot(aes(Sepal.Length, Petal.Length)) + 
  geom_point(aes(colour=Species)) +
  geom_smooth(method="lm", colour="black") + 
  geom_smooth(aes(colour=Species), method="lm") +
  labs(x="Sepal length (cm)", 
       y="Petal length (cm)", 
       title="Global vs. local aesthetics", 
       subtitle="Built-in data set `iris`")

Bells and whistles

There are endless options for making your plot just as you like. Colour can be specified with functions taking the form scale_colour_*() or scale_fill_*() (e.g., see ?scale_fill_manual). The details of the plot elements can be specified within the geom_*() functions (e.g., see ?geom_point). Panels can be added using facet_wrap(~col_A) or facet_grid(col_A ~ col_B). Axes also have their own functions (e.g., see ?scale_x_continuous). Plots also have themes, which can be specified manually using theme() or by adding pre-specified themes like theme_classic() or theme_bw().

# specify a colour scale with species as panels
iris |>
  mutate(Sepal.Area=Sepal.Length*Sepal.Width) |>
  ggplot(aes(Petal.Length, Petal.Width, colour=Sepal.Width)) + 
  geom_point() + 
  scale_colour_viridis_c() + 
  facet_wrap(~Species)

# use arguments within facet_wrap() to have 1 column of panels
iris |>
  mutate(Sepal.Area=Sepal.Length*Sepal.Width) |>
  ggplot(aes(Petal.Length, Petal.Width, colour=Sepal.Width)) + 
  geom_point() + 
  scale_colour_viridis_c() + 
  facet_wrap(~Species, ncol=1)

# specify geom_bar() arguments, add a fill, labels, theme, etc
msleep |>
  filter(!is.na(vore) & !is.na(conservation)) |>
  mutate(vore=paste0(vore, "vore"),
         conservation=factor(conservation, 
                             levels=c("domesticated", "lc", "cd", "nt", "vu", "en"))) |>
  ggplot(aes(vore, fill=conservation)) +
  geom_bar(position="fill", colour="grey30") +
  scale_fill_brewer("Conservation\nstatus", type="seq", palette="Reds") +
  labs(y="Proportion of species", 
       title="Conservation status in the msleep dataset") +
  theme_classic() +
  theme(axis.title.x=element_blank())

These can all be combined in any way you can imagine.

Explore the msleep dataset further using ggplot. For example, is there an association between sleep and brain weight as a proportion of body weight? Does this depend on feeding strategy? Do larger species spend more time sleeping?

Create a histogram of body weight with each feeding strategy in a different panel. Try transforming the x-axis to log-10 scale. Adjust the number of bins in the histograms and change the fill and colour of the bars. Add axis labels.

Code (bonus)
msleep |>
  ggplot(aes(bodywt)) + 
  geom_histogram(bins=12, colour="black", fill="steelblue") +
  scale_x_log10() +
  facet_wrap(~vore) +
  labs(x="Body weight (kg)", y="Number of species")


Working with strings

The stringr package has many convenient functions for working with strings (text). They generally follow the naming form of str_verb(). See the Strings chapter in R for Data Science for a more comprehensive overview.

Key functions:

  • str_remove() removes a pattern from each element in the vector
  • str_replace() replaces a pattern from each element in the vector
  • str_sub() extracts a subset of characters from each element in the vector
  • str_split() splits each element in the vector based on a pattern

str_remove()

msleep |>
  select(name, genus) |>
  mutate(nameNoSpace=str_remove(name, " "))
msleep |>
  select(name, genus) |>
  mutate(nameNoSpace=str_remove_all(name, " "))

str_replace()

msleep |>
  select(name, genus) |>
  mutate(nameNoSpace=str_replace(name, " ", "_"))
# A tibble: 83 × 3
   name                       genus       nameNoSpace               
   <chr>                      <chr>       <chr>                     
 1 Cheetah                    Acinonyx    Cheetah                   
 2 Owl monkey                 Aotus       Owl_monkey                
 3 Mountain beaver            Aplodontia  Mountain_beaver           
 4 Greater short-tailed shrew Blarina     Greater_short-tailed shrew
 5 Cow                        Bos         Cow                       
 6 Three-toed sloth           Bradypus    Three-toed_sloth          
 7 Northern fur seal          Callorhinus Northern_fur seal         
 8 Vesper mouse               Calomys     Vesper_mouse              
 9 Dog                        Canis       Dog                       
10 Roe deer                   Capreolus   Roe_deer                  
# ℹ 73 more rows
msleep |>
  select(name, genus) |>
  mutate(nameNoSpace=str_replace_all(name, " ", "_"))
# A tibble: 83 × 3
   name                       genus       nameNoSpace               
   <chr>                      <chr>       <chr>                     
 1 Cheetah                    Acinonyx    Cheetah                   
 2 Owl monkey                 Aotus       Owl_monkey                
 3 Mountain beaver            Aplodontia  Mountain_beaver           
 4 Greater short-tailed shrew Blarina     Greater_short-tailed_shrew
 5 Cow                        Bos         Cow                       
 6 Three-toed sloth           Bradypus    Three-toed_sloth          
 7 Northern fur seal          Callorhinus Northern_fur_seal         
 8 Vesper mouse               Calomys     Vesper_mouse              
 9 Dog                        Canis       Dog                       
10 Roe deer                   Capreolus   Roe_deer                  
# ℹ 73 more rows

str_sub()

msleep |>
  select(name, genus, order) |>
  mutate(order_abbr=str_sub(order, 1, 4))
# A tibble: 83 × 4
   name                       genus       order        order_abbr
   <chr>                      <chr>       <chr>        <chr>     
 1 Cheetah                    Acinonyx    Carnivora    Carn      
 2 Owl monkey                 Aotus       Primates     Prim      
 3 Mountain beaver            Aplodontia  Rodentia     Rode      
 4 Greater short-tailed shrew Blarina     Soricomorpha Sori      
 5 Cow                        Bos         Artiodactyla Arti      
 6 Three-toed sloth           Bradypus    Pilosa       Pilo      
 7 Northern fur seal          Callorhinus Carnivora    Carn      
 8 Vesper mouse               Calomys     Rodentia     Rode      
 9 Dog                        Canis       Carnivora    Carn      
10 Roe deer                   Capreolus   Artiodactyla Arti      
# ℹ 73 more rows

str_split()

msleep |>
  select(name, genus, order) |>
  mutate(nameSplit=str_split(name, " "))
# A tibble: 83 × 4
   name                       genus       order        nameSplit
   <chr>                      <chr>       <chr>        <list>   
 1 Cheetah                    Acinonyx    Carnivora    <chr [1]>
 2 Owl monkey                 Aotus       Primates     <chr [2]>
 3 Mountain beaver            Aplodontia  Rodentia     <chr [2]>
 4 Greater short-tailed shrew Blarina     Soricomorpha <chr [3]>
 5 Cow                        Bos         Artiodactyla <chr [1]>
 6 Three-toed sloth           Bradypus    Pilosa       <chr [2]>
 7 Northern fur seal          Callorhinus Carnivora    <chr [3]>
 8 Vesper mouse               Calomys     Rodentia     <chr [2]>
 9 Dog                        Canis       Carnivora    <chr [1]>
10 Roe deer                   Capreolus   Artiodactyla <chr [2]>
# ℹ 73 more rows
str_split(msleep$name, " ") |> head()
[[1]]
[1] "Cheetah"

[[2]]
[1] "Owl"    "monkey"

[[3]]
[1] "Mountain" "beaver"  

[[4]]
[1] "Greater"      "short-tailed" "shrew"       

[[5]]
[1] "Cow"

[[6]]
[1] "Three-toed" "sloth"     
str_split_fixed(msleep$name, " ", 3) |> head()
     [,1]         [,2]           [,3]   
[1,] "Cheetah"    ""             ""     
[2,] "Owl"        "monkey"       ""     
[3,] "Mountain"   "beaver"       ""     
[4,] "Greater"    "short-tailed" "shrew"
[5,] "Cow"        ""             ""     
[6,] "Three-toed" "sloth"        ""     
str_split_fixed(msleep$name, " ", 3)[,1]
 [1] "Cheetah"        "Owl"            "Mountain"       "Greater"       
 [5] "Cow"            "Three-toed"     "Northern"       "Vesper"        
 [9] "Dog"            "Roe"            "Goat"           "Guinea"        
[13] "Grivet"         "Chinchilla"     "Star-nosed"     "African"       
[17] "Lesser"         "Long-nosed"     "Tree"           "North"         
[21] "Asian"          "Big"            "Horse"          "Donkey"        
[25] "European"       "Patas"          "Western"        "Domestic"      
[29] "Galago"         "Giraffe"        "Pilot"          "Gray"          
[33] "Gray"           "Human"          "Mongoose"       "African"       
[37] "Thick-tailed"   "Macaque"        "Mongolian"      "Golden"        
[41] "Vole"           "House"          "Little"         "Round-tailed"  
[45] "Slow"           "Degu"           "Northern"       "Rabbit"        
[49] "Sheep"          "Chimpanzee"     "Tiger"          "Jaguar"        
[53] "Lion"           "Baboon"         "Desert"         "Potto"         
[57] "Deer"           "Phalanger"      "Caspian"        "Common"        
[61] "Potoroo"        "Giant"          "Rock"           "Laboratory"    
[65] "African"        "Squirrel"       "Eastern"        "Cotton"        
[69] "Mole"           "Arctic"         "Thirteen-lined" "Golden-mantled"
[73] "Musk"           "Pig"            "Short-nosed"    "Eastern"       
[77] "Brazilian"      "Tenrec"         "Tree"           "Bottle-nosed"  
[81] "Genet"          "Arctic"         "Red"           

Working with dates

Date-time data are notoriously prickly, and R is no exception. The lubridate package makes them much less so. See the Dates and Times chapter in R for Data Science for a more comprehensive overview.

Key functions:

  • ymd(), mdy(), dmy() convert a string with year, month, and day into a date
  • ymd_hms() converts a string to a date-time, including hour, minutes, and seconds
  • year(), month(), mday(), yday(), week() extract the corresponding component
  • adding and subtracting work intuitively
  • plotting with ggplot2 is straightforward
str(storms)
tibble [20,778 × 13] (S3: tbl_df/tbl/data.frame)
 $ name                        : chr [1:20778] "Amy" "Amy" "Amy" "Amy" ...
 $ year                        : num [1:20778] 1975 1975 1975 1975 1975 ...
 $ month                       : num [1:20778] 6 6 6 6 6 6 6 6 6 6 ...
 $ day                         : int [1:20778] 27 27 27 27 28 28 28 28 29 29 ...
 $ hour                        : num [1:20778] 0 6 12 18 0 6 12 18 0 6 ...
 $ lat                         : num [1:20778] 27.5 28.5 29.5 30.5 31.5 32.4 33.3 34 34.4 34 ...
 $ long                        : num [1:20778] -79 -79 -79 -79 -78.8 -78.7 -78 -77 -75.8 -74.8 ...
 $ status                      : Factor w/ 9 levels "disturbance",..: 7 7 7 7 7 7 7 7 8 8 ...
 $ category                    : num [1:20778] NA NA NA NA NA NA NA NA NA NA ...
 $ wind                        : int [1:20778] 25 25 25 25 25 25 25 30 35 40 ...
 $ pressure                    : int [1:20778] 1013 1013 1013 1013 1012 1012 1011 1006 1004 1002 ...
 $ tropicalstorm_force_diameter: int [1:20778] NA NA NA NA NA NA NA NA NA NA ...
 $ hurricane_force_diameter    : int [1:20778] NA NA NA NA NA NA NA NA NA NA ...
storms_df <- storms |>
  mutate(date_chr=paste(year, month, day),
         date=ymd(date_chr),
         time=ymd_h(paste(date_chr, hour))) |>
  arrange(name, time) |>
  select(name, date, time, long, lat, wind)
str(storms_df)
tibble [20,778 × 6] (S3: tbl_df/tbl/data.frame)
 $ name: chr [1:20778] "AL011993" "AL011993" "AL011993" "AL011993" ...
 $ date: Date[1:20778], format: "1993-05-31" "1993-05-31" ...
 $ time: POSIXct[1:20778], format: "1993-05-31 12:00:00" "1993-05-31 18:00:00" ...
 $ long: num [1:20778] -84 -82 -80.3 -79 -77.5 -75.8 -74 -71.8 -69.9 -67.8 ...
 $ lat : num [1:20778] 21.5 22.3 23.2 24.5 25.4 26.1 26.7 27.8 28.4 28.5 ...
 $ wind: int [1:20778] 25 25 25 25 30 30 30 30 35 35 ...
storms_df |>
  mutate(month=month(date, label=T)) |>
  ggplot(aes(month, wind)) +
  geom_boxplot() +
  scale_x_discrete(limits=month.abb)

storms_df |>
  mutate(week=week(date),
         year=year(date)) |>
  group_by(year, week) |>
  summarise(nStorms=n_distinct(name),
            mnWind=mean(wind)) |>
  ungroup() |>
  pivot_longer(c("nStorms", "mnWind"), 
               names_to="metric", values_to="value") |>
  ggplot(aes(week, value, group=year)) +
  geom_jitter(alpha=0.5, shape=1, width=0, height=0.25) +
  coord_polar() + 
  theme_bw() +
  facet_wrap(~metric, scales="free_y")

storms_df |>
  mutate(month=month(date, label=T),
         year=year(date)) |>
  group_by(year, month) |>
  summarise(nStorms=n_distinct(name),
            mnWind=mean(wind)) |>
  ggplot(aes(month, mnWind, group=year)) +
  geom_point(alpha=0.5, shape=1) +
  scale_x_discrete(limits=month.abb) +
  coord_polar() + 
  theme_bw()

storms_df |>
  mutate(month=month(date, label=T),
         year=year(date)) |>
  group_by(year, month) |>
  summarise(nStorms=n_distinct(name),
            mnWind=mean(wind)) |>
  ggplot(aes(month, nStorms, group=year)) +
  geom_point(alpha=0.5, shape=1) +
  scale_x_discrete(limits=month.abb) +
  coord_polar() + 
  theme_bw()