First things first: Make a new project in RStudio, and be sure to place it somewhere where you can easily find it (e.g., Documents, Desktop). Then download this file (the .qmd file) from the syllabus website and place it in the folder associated with that project. Then open this file in RStudio and follow along.
I encourage you to work with/talk to others throughout this tutorial, especially during times where I have you code on your own and use AI. When you code, think about the SPARCK framework:
S: Set up (What is your end goal?)
P: Pseudocode (What are the steps?)
A: AI (What are your AI prompts?)
R: Run (Run it!)
C: Check (Did your code work?)
K: Knowledge (What does your code mean?)
Agenda
Exploring our data
The grammar of graphics
ggplot syntax
Types of graphs
Polishing your graphs
Practice
Exploring our data
For today’s tutorial, we’re going to use a dataset from the NYPD on Stop and Frisk. Search “stop and frisk” on NYC Open Data, then download the Excel file for 2025 from the NYPD website. Put the data in your folder for this tutorial. Then, load the data in. (Hint: Since this is an excel file, and not a csv file, you’ll need to load a package called readxl (this is already installed from when you installed tidyverse), and then use the read_excel() function.)
library(tidyverse)
Warning: package 'ggplot2' was built under R version 4.4.3
Warning: package 'tidyr' was built under R version 4.4.3
Warning: package 'purrr' was built under R version 4.4.3
Warning: package 'dplyr' was built under R version 4.4.3
-- Attaching core tidyverse packages ------------------------ tidyverse 2.0.0 --
v dplyr 1.2.0 v readr 2.1.5
v forcats 1.0.0 v stringr 1.5.1
v ggplot2 4.0.2 v tibble 3.2.1
v lubridate 1.9.4 v tidyr 1.3.2
v purrr 1.2.1
-- Conflicts ------------------------------------------ tidyverse_conflicts() --
x dplyr::filter() masks stats::filter()
x dplyr::lag() masks stats::lag()
i Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(readxl)sqf <-read_excel('sqf-2025.xlsx')
Warning: Expecting numeric in BL9757 / R9757C64: got '(null)'
Warning: Expecting numeric in BL9770 / R9770C64: got '(null)'
Warning: Expecting numeric in BL9780 / R9780C64: got '(null)'
Warning: Expecting numeric in BL9781 / R9781C64: got '(null)'
Warning: Expecting numeric in BL17208 / R17208C64: got '(null)'
Warning: Expecting numeric in BL19630 / R19630C64: got '(null)'
Warning: Expecting numeric in BL20205 / R20205C64: got '(null)'
Warning: Expecting numeric in BL25545 / R25545C64: got '(null)'
Before we get started visualizing, you should explore the data a bit. What are some things you notice about it? What are some interesting variables?
Answer: Each row is one stop. There are 81 columns describing when and where the stop happened, who was stopped (e.g., age, sex, race, height, weight), why they were stopped, and what happened during the stop (e.g., whether they were frisked, searched, or arrested, and whether a weapon was found). Missing data is written as (null), and the column names are all in capital letters.
There are a few things you might notice that we need to immediately do to clean this data and make it a bit more usable:
Clean the names of the columns using the clean_names() function from the janitor package
Turn all the (null) values to NA, which is how R signifies missing data.
There is a variable called stop_duration_minutes that has some values that are over 10 hours long. Those are probably mistakes. Get rid of those rows so they don’t mess up our averages later on.
There are 8 people whose age wasn’t recorded (NA). We’ll be graphing age a lot, so get rid of those rows too.
Go ahead and clean the data now. You may encounter code from AI when cleaning that you haven’t seen before. That’s okay! Just make sure you a) check that it worked correctly, and b) that you have a basic understanding of what it’s doing.
library(janitor)
Attaching package: 'janitor'
The following objects are masked from 'package:stats':
chisq.test, fisher.test
sqf <-clean_names(sqf)
Warning in FUN(X[[i]], ...): unable to translate '<U+00C4>' to native encoding
Warning in FUN(X[[i]], ...): unable to translate '<U+00D6>' to native encoding
Warning in FUN(X[[i]], ...): unable to translate '<U+00DC>' to native encoding
Warning in FUN(X[[i]], ...): unable to translate '<U+00E4>' to native encoding
Warning in FUN(X[[i]], ...): unable to translate '<U+00F6>' to native encoding
Warning in FUN(X[[i]], ...): unable to translate '<U+00FC>' to native encoding
Warning in FUN(X[[i]], ...): unable to translate '<U+00DF>' to native encoding
Warning in FUN(X[[i]], ...): unable to translate '<U+00C6>' to native encoding
Warning in FUN(X[[i]], ...): unable to translate '<U+00E6>' to native encoding
Warning in FUN(X[[i]], ...): unable to translate '<U+00D8>' to native encoding
Warning in FUN(X[[i]], ...): unable to translate '<U+00F8>' to native encoding
Warning in FUN(X[[i]], ...): unable to translate '<U+00C5>' to native encoding
Warning in FUN(X[[i]], ...): unable to translate '<U+00E5>' to native encoding
Today’s lesson is rooted in the data visualization theory known as the grammar of graphics (Wilkinson, 2005), developed by Leland Wilkinson. The description below is adapted from Ismay et al. (2025).
At their most basic, graphics/plots/charts (we’ll use these terms interchangeably) provide a nice way to explore the patterns in data, such as the presence of outliers, distributions of individual variables, and relationships between groups of variables. Graphics are designed to emphasize the findings and insights you want your audience to understand. This does, however, require a balancing act. On the one hand, you want to highlight as many interesting findings as possible. On the other hand, you don’t want to include so much information that it overwhelms your audience.
As we will see, plots also help us to identify patterns and outliers in our data. We’ll see that a common extension of these ideas is to compare the distribution of one numerical variable, such as what are the center and spread of the values, as we go across the levels of a different categorical variable.
We start with a discussion of a theoretical framework for data visualization known as “the grammar of graphics.” Think of how we construct and form sentences in English by combining different elements, like nouns, verbs, articles, subjects, objects, etc. We can’t just combine these elements in any arbitrary order; we must do so following a set of rules known as a linguistic grammar. Similarly to a linguistic grammar, “the grammar of graphics” defines a set of rules for constructing statistical graphics by combining different types of layers.
In short, the grammar tells us that:
A statistical graphic is a mapping of data variables to aesthetic attributes of geometric objects.
Specifically, we can break a graphic into the following three essential components:
data: the dataset containing the variables of interest.
geom: the geometric object in question. This refers to the type of object we can observe in a plot. For example: points, lines, and bars.
aes: aesthetic attributes of the geometric object. For example, x/y position, color, shape, and size. Aesthetic attributes are mapped to variables in the dataset.
How does this translate to coding in R? Basically, when we make visualizations in R, we need to do three things to make a basic plot:
Tell our visualization package what our data is (data)
Tell our visualization package what relationships we want to see (aes)
Tell our visualization package how we want to see those relationships (geom)
ggplot syntax
The visualization package that most scientists use to make graphs in R is called ggplot. The gg stands for “grammar of graphics,” meaning that this package operates according to that grammar we laid out earlier. Let’s build our first graph piece by piece so we can see how it all comes together.
First, what happens if we just tell ggplot() the data we want to use?
ggplot(data = sqf)
Nothing! Remember, we need to tell ggplot three things in order to get a basic graph. Let’s next tell it what relationships we want to see. Specifically, let’s say we want to see the spread of ages of people who are stopped.
A little better! But still nothing interesting. Let’s now tell it how we want to see the relationships by specifying the type of graph. Let’s visualize the spread of ages using a histogram.
`stat_bin()` using `bins = 30`. Pick better value `binwidth`.
Hooray! We made a graph! It’s ugly, but it’s visualizing real information that we can report on. Around what age is most commonly stopped?
Answer: 20ish
The above code - in which we specify our data, our aesthetics, and a geometric object - forms the basis of every graph we will make in this class.
Types of graphs
There are many kinds of graphs we can make in ggplot, many of them beginning with geom_. You can see the full list here. Each type of graph comes with its own options to consider and customize the graph. Let’s go through a few of the most important ones.
Histogram
The graph we just made was a histogram. One important way we might wish to customize the way the data is shown is by how many “bins” there are. In this case, a “bin” is just a bar - our data are grouped into bars with specific ranges of values to calculate the counts. Use the bins = argument to make a graph with a different number of bins. (The default is 30).
Let’s make this graph a bit more aesthetically pleasing! Add some color using the color = and fill = arguments. You can see a list of named colors in R here.
Another useful type of graph to visualize the spread of your data is the boxplot. What’s nice about ggplot and using a grammar of graphics is that if you want to display the same data in a different format, the only part of your code you need to change is where you specify the geom.
Use geom_boxplot() to display the spread of ages across the entire dataset, with the age variable on the x axis.
This shows the median age of person who is stopped (the thick line), the 75th and 25th percentiles (top and bottom of box), all data within 1.5x the interquartile range (the whiskers), and data outside of that range aka outliers (the dots).
Any guesses how we can get this to display the box vertically, which is how boxplots are typically displayed?
So this plot is showing the spread of our data across the entire dataset. But often, boxplots are useful because they allow us to compare data distributions for different categories in our data. In order to do this, we need to add an additional aesthetic, because we need to specify an additional axis.
Make a boxplot that shows the spread of age stopped, broken down by sex.
There are two ways to make bar graphs in ggplot: geom_bar() and geom_col(). They have slightly different use cases based on the structure of your data:
geom_bar()calculates counts based on your raw data
geom_col() expects your data to already be summarized and takes raw numbers in your data
Our data here is not pre-counted. Use geom_bar() to display a bar graph of the number of stops in each borough.
geom_bar() defaults to “count” as the summary variable of interest, but you can specify any summary variable you’re interested in by providing additional arguments. For example, use geom_bar() to make a bar graph that shows the average age of people stopped, by borough. (You’ll have to add an additional aesthetic.)
Another very useful aspect of bar graphs is to display multiple categories for each x axis value. For example, let’s say you wanted to show the average stop length in each borough, broken down by sex.
This is going to require you to specify an additional aesthetic, which is the variable by which the bars should be filled in. This is different from what we did earlier when we specified a specific color as an argument within the geom. Now, color (or in this case, fill =) is an aesthetic, because it conveys information.
Then, we use the position = 'dodge' argument within geom_bar() to make the bars display next to each other.
Use position = 'dodge' to make a bar graph that shows average stop length by borough, with different colored bars by sex.
ggplot(data = sqf,mapping =aes(x = stop_location_boro_name,y = stop_duration_minutes, fill = suspect_sex)) +geom_bar(stat ='summary',fun ='mean', position ='dodge')
Make this graph cleaner by getting rid of datapoints where borough is not recorded (NA).
ggplot(data = sqf |>filter(!is.na(stop_location_boro_name)),mapping =aes(x = stop_location_boro_name,y = stop_duration_minutes, fill = suspect_sex)) +geom_bar(stat ='summary',fun ='mean', position ='dodge')
Scatterplots
Scatterplots are very useful for showing relationships between two continuous variables. There are two geoms you might want to use to make a scatter plot. First, use geom_point() to graph the relationship between stop duration and age.
ggplot(sqf, aes(y = stop_duration_minutes, x = suspect_reported_age)) +geom_point()
Okay, there’s some sort of pattern here, where we can see that older people who are stopped tend to have shorter stops. But it’s hard to see what’s going on with our data exactly. There are two things we should do to make our data clearer.
First, let’s use geom_jitter() instead of geom_point(), which “jitters” the true value of our data when plotting, so that a bunch of data points with the same value aren’t stacked on top of each other.
ggplot(sqf, aes(y = stop_duration_minutes, x = suspect_reported_age)) +geom_jitter()
See how the dots are a little less lined up than they were before?
Next, we need to use the alpha = argument, which specifies how transparent our dots are. This allows us to see where data is “denser” because the dots will be darker. Make the above plot but with the alpha = .1 argument.
ggplot(sqf, aes(y = stop_duration_minutes, x = suspect_reported_age)) +geom_jitter(alpha = .1)
Line graph
The best use of line graphs is probably for showing effects over time. This means that your x axis is most likely going to be some sort of time variable.
Create a line graph that shows the number of stops each day of the year. This will require several steps, including
Creating a new variable that converts your date column to a formal “datetime” data type
Summarizing it so you have a count of the number of stops each day
Graphing a line graph with geom_line()
(Hint: The above is basically pseudocode! It’s good to get used to thinking about coding like the above.)
sqf |>mutate(stop_frisk_date =ymd(stop_frisk_date)) |>count(stop_frisk_date, name ="num_stops") |>ggplot(aes(x = stop_frisk_date, y = num_stops)) +geom_line()
Combining plots
What’s great about ggplot is that you can combine multiple plots in one graph. For example, let’s make the above graph but with points added in using geom_point().
sqf |>mutate(stop_frisk_date =ymd(stop_frisk_date)) |>count(stop_frisk_date, name ="num_stops") |>ggplot(aes(x = stop_frisk_date, y = num_stops)) +geom_line() +geom_point()
A useful way of combining multiple graphs is to show a summary and the raw data at the same time.
Make a bar graph (using geom_bar()) that shows the average age of stops by borough, with raw data included using geom_jitter().
ggplot(sqf,aes(x = stop_location_boro_name,y = suspect_reported_age)) +geom_bar(stat ='summary', fun ='mean') +geom_jitter()
Yikes, that looks horrible. Adjust the alpha of the points to .1, and use the width = argument so that the dots are not jittered as widely (I’d recommend .1). And give it some color! (Make the bars and dots different colors.)
ggplot(sqf,aes(x = stop_location_boro_name,y = suspect_reported_age)) +geom_bar(stat ='summary', fun ='mean', fill ='slateblue', color ='black') +geom_jitter(alpha = .1, width = .1, color ='lightcoral')
Polishing your graphs
Congratulations on being a plotter extraordinaire!
But here’s the thing. There’s actually a TON more types of layers that ggplot can add to improve the look of your graph. I’ll go over a few basics here.
First is the labels, using the labs() function. This allows you to specify your labels nicely. Make the above graph but with a clearly labeled X axis, Y axis, and title.
ggplot(sqf,aes(x = stop_location_boro_name,y = suspect_reported_age)) +geom_bar(stat ='summary', fun ='mean', fill ='slateblue', color ='black') +geom_jitter(alpha = .1, width = .1, color ='lightcoral') +labs(x ='Borough', y ='Age',title ='Age by borough')
You can also change the way the background looks by specifying the theme_. My favorites are theme_bw() and theme_classic(). You can find the full list here. Try one below.
ggplot(sqf,aes(x = stop_location_boro_name,y = suspect_reported_age)) +geom_bar(stat ='summary', fun ='mean', fill ='slateblue', color ='black') +geom_jitter(alpha = .1, width = .1, color ='lightcoral') +labs(x ='Borough', y ='Age',title ='Age by borough') +theme_classic()
You might also want to make all of your font bigger. In fact, the default font size in ggplot graphs is way too small for a presentation. Make a version of your graph where everything has a bigger font below.
ggplot(sqf,aes(x = stop_location_boro_name,y = suspect_reported_age)) +geom_bar(stat ='summary', fun ='mean', fill ='slateblue', color ='black') +geom_jitter(alpha = .1, width = .1, color ='lightcoral') +labs(x ='Borough', y ='Age',title ='Age by borough') +theme_classic(base_size =16)
If you find that doing this makes the text of your x axis overlap, you can adjust the angle of the text.
ggplot(sqf,aes(x = stop_location_boro_name,y = suspect_reported_age)) +geom_bar(stat ='summary', fun ='mean', fill ='slateblue', color ='black') +geom_jitter(alpha = .1, width = .1, color ='lightcoral') +labs(x ='Borough', y ='Age',title ='Age by borough') +theme_classic(base_size =16) +theme(axis.text.x =element_text(angle =45, hjust =1))
Basically, you have complete control over what your graph looks like in ggplot. You should familiarize yourself with all of the options so that you can describe to AI exactly what you’d like to change, and how.
There are several topics that we didn’t cover in this tutorial, such as color palettes and facetting. We’ll encounter these in our “advanced visualization” tutorial, or you may encounter them on your own before then.
Saving your graphs
One more thing! If you make a beautiful graph, you’ll likely want to save it. You can do that using the ggsave() function. If you call it right after displaying your graph, it will save whatever is displayed. You just need to specify the name of the file you want it saved to.
ggplot(sqf,aes(x = stop_location_boro_name,y = suspect_reported_age)) +geom_bar(stat ='summary', fun ='mean', fill ='slateblue', color ='black') +geom_jitter(alpha = .1, width = .1, color ='lightcoral') +labs(x ='Borough', y ='Age',title ='Age by borough') +theme_classic(base_size =16) +theme(axis.text.x =element_text(angle =45, hjust =1))
ggsave('mygraph.jpg')
Saving 7 x 5 in image
Practice
AI can help you create graphs, but it’s less helpful at figuring out what the most exciting datapoints are that you want to visualize. This is where you - as a thinking, creative budding young scientist and human - come in.
Make your own graph below, using SPARCK. It can be any graph type you want. It has to involve at least one column that we haven’t used yet in this tutorial. Show us something interesting! It’s okay if you need to do some cleaning or summarizing on your data before creating the graph.
S:
P:
A:
R:
C:
K:
References
Ismay, C., Kim, A. Y., & Valdivia, A. (2025). The grammar of graphics. In Statistical inference via data science: A ModernDive into R and the tidyverse (2nd ed., Section 2.1). https://moderndive.com/v2/02-visualization.html
Wilkinson, L. (2005). The grammar of graphics (2nd ed.). Springer.