# Load libraries
library(tidyverse)
library(knitr)NYPD Hate Crimes 2019-2026
# Set working directory and read file
# Set working directory
setwd("Z:/R/MC/DATA_110_Maliha")
# Read file and assign to 'hatecrimes'
hatecrimes <- read_csv("NYPD_Hate_Crimes_19-26.csv")# Make all headers lowercase and remove spaces
# All headers lowercase
names(hatecrimes) <- tolower(names(hatecrimes))
# Remove spaces
names(hatecrimes) <- gsub(" ","",names(hatecrimes))
# Show dataset (first 6 rows)
head(hatecrimes)# A tibble: 6 × 14
fullcomplaintid complaintyearnumber monthnumber recordcreatedate
<dbl> <dbl> <dbl> <chr>
1 2.02e14 2019 1 1/23/2019
2 2.02e14 2019 2 2/25/2019
3 2.02e14 2019 2 2/27/2019
4 2.02e14 2019 4 4/16/2019
5 2.02e14 2019 6 6/20/2019
6 2.02e14 2019 7 7/31/2019
# ℹ 10 more variables: complaintprecinctcode <dbl>, patrolboroughname <chr>,
# county <chr>, lawcodecategorydescription <chr>, offensedescription <chr>,
# pdcodedescription <chr>, biasmotivedescription <chr>,
# offensecategory <chr>, arrestdate <lgl>, arrestid <chr>
# Explore the bias motive (biasmotivedescription)
bias_count <- hatecrimes |>
select(biasmotivedescription) |>
group_by(biasmotivedescription) |>
count() |>
arrange(desc(n))
head(bias_count)# A tibble: 6 × 2
# Groups: biasmotivedescription [6]
biasmotivedescription n
<chr> <int>
1 ANTI-JEWISH 1906
2 ANTI-MALE HOMOSEXUAL (GAY) 489
3 ANTI-ASIAN 401
4 ANTI-BLACK 315
5 ANTI-OTHER ETHNICITY 168
6 ANTI-MUSLIM 156
# Visualize these counts as a bar graph
ggplot(hatecrimes, aes(x = biasmotivedescription)) + geom_bar()# Use inclusion/exclusion criteria to filter
# Filter the table for the top 10.
# This time, I use the bias_count subset with geom_col()
bias_count |>
head(10) |>
ggplot(aes(x=biasmotivedescription, y = n)) +
geom_col()# Arrange the bars according to height and rotate
# I use “reorder” and “coord_flip”
bias_count |>
head(10) |>
ggplot(aes(x=reorder(biasmotivedescription, n), y = n)) +
geom_col() +
coord_flip()# Add title, caption for the data source, and x-axis label
bias_count |>
head(10) |>
ggplot(aes(x=reorder(biasmotivedescription, n), y = n)) +
geom_col() +
coord_flip()+
labs(x = "",
y = "Counts of hatecrime types based on motive",
title = "Bar Graph of Hate Crimes from 2019-2026",
subtitle = "Counts based on the hatecrime motive",
caption = "Source: NY State Division of Criminal Justice Services")# Add color and change the theme
bias_count |>
head(10) |>
ggplot(aes(x=reorder(biasmotivedescription, n), y = n)) +
geom_col(fill = "salmon") +
coord_flip()+
labs(x = "",
y = "Counts of hatecrime types based on motive",
title = "Bar Graph of Hate Crimes from 2019-2026",
subtitle = "Counts based on the hatecrime motive",
caption = "Source: NY State Division of Criminal Justice Services") +
theme_minimal()# Add annotations for counts and remove the x-axis values
bias_count |>
head(10) |>
ggplot(aes(x=reorder(biasmotivedescription, n), y = n)) +
geom_col(fill = "salmon") +
coord_flip()+
labs(x = "",
y = "Counts of hatecrime types based on motive",
title = "Bar Graph of Hate Crimes from 2019-2026",
subtitle = "Counts based on the hatecrime motive",
caption = "Source: NY State Division of Criminal Justice Services") +
theme_minimal()+
geom_text(aes(label = n), hjust = -.05, size = 3) +
theme(axis.text.x = element_blank())Look deeper into crimes against Jewish, Asian, Black people, and gay males
First check the year totals
# First check the year totals
hate_year <- hatecrimes |>
filter(biasmotivedescription %in% c("ANTI-JEWISH", "ANTI-MALE HOMOSEXUAL (GAY)", "ANTI-ASIAN", "ANTI-BLACK"))|>
group_by(complaintyearnumber) |>
count(biasmotivedescription)|>
arrange(desc(n))
hate_year# A tibble: 28 × 3
# Groups: complaintyearnumber [7]
complaintyearnumber biasmotivedescription n
<dbl> <chr> <int>
1 2024 ANTI-JEWISH 371
2 2023 ANTI-JEWISH 343
3 2025 ANTI-JEWISH 320
4 2022 ANTI-JEWISH 279
5 2019 ANTI-JEWISH 252
6 2021 ANTI-JEWISH 215
7 2021 ANTI-ASIAN 150
8 2020 ANTI-JEWISH 126
9 2023 ANTI-MALE HOMOSEXUAL (GAY) 116
10 2022 ANTI-ASIAN 91
# ℹ 18 more rows
Then check the county totals
# Then check the county totals
hate_county <- hatecrimes |>
filter(biasmotivedescription %in% c("ANTI-JEWISH", "ANTI-MALE HOMOSEXUAL (GAY)", "ANTI-ASIAN", "ANTI-BLACK"))|>
group_by(county) |>
count(biasmotivedescription)|>
arrange(desc(n))
hate_county# A tibble: 20 × 3
# Groups: county [5]
county biasmotivedescription n
<chr> <chr> <int>
1 KINGS ANTI-JEWISH 798
2 NEW YORK ANTI-JEWISH 651
3 QUEENS ANTI-JEWISH 289
4 NEW YORK ANTI-MALE HOMOSEXUAL (GAY) 237
5 NEW YORK ANTI-ASIAN 228
6 KINGS ANTI-MALE HOMOSEXUAL (GAY) 120
7 KINGS ANTI-BLACK 99
8 BRONX ANTI-JEWISH 92
9 QUEENS ANTI-MALE HOMOSEXUAL (GAY) 91
10 KINGS ANTI-ASIAN 80
11 NEW YORK ANTI-BLACK 79
12 QUEENS ANTI-ASIAN 78
13 RICHMOND ANTI-JEWISH 76
14 QUEENS ANTI-BLACK 75
15 BRONX ANTI-MALE HOMOSEXUAL (GAY) 35
16 RICHMOND ANTI-BLACK 35
17 BRONX ANTI-BLACK 27
18 BRONX ANTI-ASIAN 10
19 RICHMOND ANTI-MALE HOMOSEXUAL (GAY) 6
20 RICHMOND ANTI-ASIAN 5
Check information combining totals from counties and years
# Check information combining totals from counties and years
hate2 <- hatecrimes |>
filter(biasmotivedescription %in% c("ANTI-JEWISH", "ANTI-MALE HOMOSEXUAL (GAY)", "ANTI-ASIAN", "ANTI-BLACK"))|>
group_by(complaintyearnumber, county) |>
count(biasmotivedescription)|>
arrange(desc(n))
hate2# A tibble: 127 × 4
# Groups: complaintyearnumber, county [35]
complaintyearnumber county biasmotivedescription n
<dbl> <chr> <chr> <int>
1 2024 KINGS ANTI-JEWISH 152
2 2024 NEW YORK ANTI-JEWISH 136
3 2025 KINGS ANTI-JEWISH 136
4 2019 KINGS ANTI-JEWISH 128
5 2023 KINGS ANTI-JEWISH 126
6 2022 KINGS ANTI-JEWISH 125
7 2023 NEW YORK ANTI-JEWISH 124
8 2025 NEW YORK ANTI-JEWISH 110
9 2022 NEW YORK ANTI-JEWISH 104
10 2021 NEW YORK ANTI-ASIAN 84
# ℹ 117 more rows
Plot these three types of hate crimes together
# Plot these three types of hate crimes together
# position = “dodge” makes side-by-side bars
# stat = “identity” allows to plot each set of bars for each year between 2010 and 2016
# ggtitle gives the plot a title
# labs gives a title to the legend
ggplot(data = hate2) +
geom_bar(aes(x=complaintyearnumber, y=n, fill = biasmotivedescription),
position = "dodge", stat = "identity") +
labs(fill = "Hate Crime Type",
y = "Number of Hate Crime Incidents",
title = "Hate Crime Type in NY Counties Between 2010-2016",
caption = "Source: NY State Division of Criminal Justice Services")What about the counties?
# make bar graphs by county instead of by year
ggplot(data = hate2) +
geom_bar(aes(x=county, y=n, fill = biasmotivedescription),
position = "dodge", stat = "identity") +
labs(fill = "Hate Crime Type",
y = "Number of Hate Crime Incidents",
title = "Hate Crime Type in NY Counties Between 2010-2016",
caption = "Source: NY State Division of Criminal Justice Services")The highest counts
Put it all together with years and counties using “facet”
# Put it all together with years and counties using “facet”
ggplot(data = hate2) +
geom_bar(aes(x=complaintyearnumber, y=n, fill = biasmotivedescription),
position = "dodge", stat = "identity") +
facet_wrap(~county) +
labs(fill = "Hate Crime Type",
y = "Number of Hate Crime Incidents",
title = "Hate Crime Type in NY Counties Between 2010-2016",
caption = "Source: NY State Division of Criminal Justice Services")How would calculations be affected by looking at hate crimes in counties per year by population densities?
# Bring in census data for populations of New York counties. These are estimates from the 2010 census.
# Set working directory
setwd("Z:/R/MC/DATA_110_Maliha")
# Read file and assign to 'nypop'
nypop <- read_csv("nyc_census_pop_2020.csv")Rows: 62 Columns: 4
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (2): Area Name, Population Percent Change
num (2): 2020 Census Population, Population Change
ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
Clean the county name to match the other dataset
# Rename the variable "Geography" as "county" so that it matches in the other dataset.
nypop$`Area Name` <- gsub(" County", "", nypop$`Area Name`)
nypop2 <- nypop |>
rename(county = `Area Name`)|>
select(county, `2020 Census Population`)
head(nypop2)# A tibble: 6 × 2
county `2020 Census Population`
<chr> <dbl>
1 Albany 314848
2 Allegany 46456
3 Bronx 1472654
4 Broome 198683
5 Cattaraugus 77042
6 Cayuga 76248
Join the hate2 data with nypop
# Join the hate2 data with nypop
datajoin <- left_join(hate2, nypop2, by=c("county"))
datajoin# A tibble: 127 × 5
# Groups: complaintyearnumber, county [35]
complaintyearnumber county biasmotivedescription n 2020 Census Populati…¹
<dbl> <chr> <chr> <int> <dbl>
1 2024 KINGS ANTI-JEWISH 152 NA
2 2024 NEW Y… ANTI-JEWISH 136 NA
3 2025 KINGS ANTI-JEWISH 136 NA
4 2019 KINGS ANTI-JEWISH 128 NA
5 2023 KINGS ANTI-JEWISH 126 NA
6 2022 KINGS ANTI-JEWISH 125 NA
7 2023 NEW Y… ANTI-JEWISH 124 NA
8 2025 NEW Y… ANTI-JEWISH 110 NA
9 2022 NEW Y… ANTI-JEWISH 104 NA
10 2021 NEW Y… ANTI-ASIAN 84 NA
# ℹ 117 more rows
# ℹ abbreviated name: ¹`2020 Census Population`
It didn’t work - the new column has NA values
# It didn’t work - the new column has NA values
# The counties are upper case in hate2 and mixed in nypop
# Convert to lowercase
hate_new <- hate2 |>
mutate(county = as_factor(str_to_lower(as.character(county))))
# Convert to lowercase
nypop_new <- nypop2 |>
mutate(county = as_factor(str_to_lower(as.character(county))))Try joining again
# Joining again
datajoin <- left_join(hate_new, nypop_new, by=c("county"))
datajoin# A tibble: 127 × 5
# Groups: complaintyearnumber, county [35]
complaintyearnumber county biasmotivedescription n 2020 Census Populati…¹
<dbl> <fct> <chr> <int> <dbl>
1 2024 kings ANTI-JEWISH 152 2736074
2 2024 new y… ANTI-JEWISH 136 1694251
3 2025 kings ANTI-JEWISH 136 2736074
4 2019 kings ANTI-JEWISH 128 2736074
5 2023 kings ANTI-JEWISH 126 2736074
6 2022 kings ANTI-JEWISH 125 2736074
7 2023 new y… ANTI-JEWISH 124 1694251
8 2025 new y… ANTI-JEWISH 110 1694251
9 2022 new y… ANTI-JEWISH 104 1694251
10 2021 new y… ANTI-ASIAN 84 1694251
# ℹ 117 more rows
# ℹ abbreviated name: ¹`2020 Census Population`
Calculate the rate of incidents per 100,000. Then arrange in descending order
# Calculate the rate of incidents per 100,000. Then arrange in descending order
datajoinrate <- datajoin |>
mutate(rate = n/`2020 Census Population`* 100000) |>
arrange(desc(rate))
datajoinrate# A tibble: 127 × 6
# Groups: complaintyearnumber, county [35]
complaintyearnumber county biasmotivedescription n 2020 Census Populati…¹
<dbl> <fct> <chr> <int> <dbl>
1 2024 new y… ANTI-JEWISH 136 1694251
2 2023 new y… ANTI-JEWISH 124 1694251
3 2025 new y… ANTI-JEWISH 110 1694251
4 2022 new y… ANTI-JEWISH 104 1694251
5 2024 kings ANTI-JEWISH 152 2736074
6 2025 kings ANTI-JEWISH 136 2736074
7 2021 new y… ANTI-ASIAN 84 1694251
8 2021 new y… ANTI-JEWISH 84 1694251
9 2019 kings ANTI-JEWISH 128 2736074
10 2023 kings ANTI-JEWISH 126 2736074
# ℹ 117 more rows
# ℹ abbreviated name: ¹`2020 Census Population`
# ℹ 1 more variable: rate <dbl>
My Essay
Positive and negative aspects
The strengths of this dataset lie in its coverage – seven years (2019-2026) of data in all NYC boroughs – and detail, with each event being categorized into 44 variables such as bias motive, offense type, precinct, county, etc. The level of detail here allows one to track trends – e.g., the growth of anti-Jewish incidents in Kings and New York counties between 2020 and 2024. Moreover, the combination with census population figures allows one to calculate per capita rates of these events rather than counts.
A huge weakness in the data is in its collection methodology. As reported in the ProPublica news cited in the tutorial, the FBI asks the local police departments to voluntarily report hate crimes and cannot enforce the reporting. Therefore, this dataset underreports hate crimes, particularly among the population segments with lower trust in law enforcement, language barriers, or immigration-related fear. In addition, some bias categories show extremely low counts (as few as 1-2) making the year-to-year comparison for this segment meaningless.
Analysis opportunities
Reporting-gap analysis: compare this NYPD dataset against victimization survey data (e.g., NCVS) or advocacy-group tallies for the same period and measure the level of underreporting by bias categories or by borough. Arrest/resolution outcomes: Use the arrestdate and arrestid fields to study what share of hate crimes by category actually result in an arrest, and whether that rate differs by borough or bias type.