DATA 110 Project 1

Introduction

This project explores fatal police shootings recorded in the United States from January 2015 through December 2022 in the supplied dataset. Each row represents a recorded incident and includes variables such as the date, age, race, gender, state, city, manner of death, whether the person was reported to show signs of mental illness, whether a body camera was present, and geographic coordinates. Race is coded in the source data as W (White), B (Black), H (Hispanic), A (Asian), N (Native American), and O (Other); missing race values are treated as unknown for the categorical visualization.

I will explore two questions: how the recorded incidents are distributed across reported race categories, and how the number of incidents recorded per year changed over time. I will also fit a simple linear regression to the annual totals to summarize the overall time trend. These are descriptions of the supplied records, not estimates of all police encounters or proof of why patterns occur.

Data source: The supplied fatal-police-shootings-data.csv file is associated with The Washington Post’s Fatal Force project, which tracks fatal shootings by on-duty police officers. Project information: https://www.washingtonpost.com/graphics/investigations/police-shootings-database/.

library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.2.1     ✔ readr     2.2.0
✔ forcats   1.0.1     ✔ stringr   1.6.0
✔ ggplot2   4.0.3     ✔ tibble    3.3.1
✔ lubridate 1.9.5     ✔ tidyr     1.3.2
✔ purrr     1.2.2     
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(dplyr)
library(ggplot2)

This chunk reads the supplied CSV with readr::read_csv() and checks the structure before making changes.

fatal_shootings <- readr::read_csv("fatal-police-shootings-data.csv", show_col_types = FALSE)

glimpse(fatal_shootings)
Rows: 8,002
Columns: 17
$ id                      <dbl> 3, 4, 5, 8, 9, 11, 13, 15, 16, 17, 19, 21, 22,…
$ name                    <chr> "Tim Elliot", "Lewis Lee Lembke", "John Paul Q…
$ date                    <date> 2015-01-02, 2015-01-02, 2015-01-03, 2015-01-0…
$ manner_of_death         <chr> "shot", "shot", "shot and Tasered", "shot", "s…
$ armed                   <chr> "gun", "gun", "unarmed", "toy weapon", "nail g…
$ age                     <dbl> 53, 47, 23, 32, 39, 18, 22, 35, 34, 47, 25, 31…
$ gender                  <chr> "M", "M", "M", "M", "M", "M", "M", "M", "F", "…
$ race                    <chr> "A", "W", "H", "W", "H", "W", "H", "W", "W", "…
$ city                    <chr> "Shelton", "Aloha", "Wichita", "San Francisco"…
$ state                   <chr> "WA", "OR", "KS", "CA", "CO", "OK", "AZ", "KS"…
$ signs_of_mental_illness <lgl> TRUE, FALSE, FALSE, TRUE, FALSE, FALSE, FALSE,…
$ threat_level            <chr> "attack", "attack", "other", "attack", "attack…
$ flee                    <chr> "Not fleeing", "Not fleeing", "Not fleeing", "…
$ body_camera             <lgl> FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALS…
$ longitude               <dbl> -123.122, -122.892, -97.281, -122.422, -104.69…
$ latitude                <dbl> 47.247, 45.487, 37.695, 37.763, 40.384, 35.877…
$ is_geocoding_exact      <lgl> TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE…
dim(fatal_shootings)
[1] 8002   17
names(fatal_shootings)
 [1] "id"                      "name"                   
 [3] "date"                    "manner_of_death"        
 [5] "armed"                   "age"                    
 [7] "gender"                  "race"                   
 [9] "city"                    "state"                  
[11] "signs_of_mental_illness" "threat_level"           
[13] "flee"                    "body_camera"            
[15] "longitude"               "latitude"               
[17] "is_geocoding_exact"     

Clean and prepare the data

The date is converted from text to a date type, and a year variable is extracted for time-based summaries. Missing race values are retained as an explicit Unknown / not reported category rather than deleting those records. For the race chart, records with missing race are therefore visible. Age is not filled in because an unknown age should not be replaced with an invented value. The analysis uses all rows in the supplied file; the original rows are retained in shootings_raw.

shootings <- fatal_shootings |>
  mutate(
    date = lubridate::ymd(date),
    year = lubridate::year(date),
    race_group = case_when(
      race == "W" ~ "White",
      race == "B" ~ "Black",
      race == "H" ~ "Hispanic",
      race == "A" ~ "Asian",
      race == "N" ~ "Native American",
      race == "O" ~ "Other",
      is.na(race) ~ "Unknown / not reported",
      TRUE ~ "Other / uncategorized"
    ),
    manner_label = recode(
      manner_of_death,
      "shot" = "Shot",
      "shot and Tasered" = "Shot and Tasered"
    )
  )

# Review missing values and date coverage to document the quality of the supplied data.
missing_summary <- shootings |>
  summarise(
    records = n(),
    first_date = min(date, na.rm = TRUE),
    last_date = max(date, na.rm = TRUE),
    missing_age = sum(is.na(age)),
    missing_race = sum(is.na(race)),
    missing_armed = sum(is.na(armed)),
    missing_gender = sum(is.na(gender)),
    missing_flee = sum(is.na(flee)),
    missing_coordinates = sum(is.na(latitude) | is.na(longitude))
  )

missing_summary
# A tibble: 1 × 9
  records first_date last_date  missing_age missing_race missing_armed
    <int> <date>     <date>           <int>        <int>         <int>
1    8002 2015-01-02 2022-12-01         503         1517           211
# ℹ 3 more variables: missing_gender <int>, missing_flee <int>,
#   missing_coordinates <int>

The data set has missing values in several fields, including race and age. I do not remove these rows from the full data set because doing so would reduce the number of incidents available for the overall count. Instead, the race chart labels missing race information as unknown. The annual totals use the incident date, which is present for the supplied records.

Visualization 1: Recorded incidents by race category

This chart counts records by the race category recorded in the dataset. A custom color palette is used rather than the default ggplot palette. The unknown category is included so that missing race information is not hidden.

# Count the records in each race category and order the categories.
race_counts <- shootings |>
  count(race_group, name = "incidents") |>
  mutate(
    race_group = forcats::fct_reorder(
      race_group,
      incidents
    ))

# Choosing custom colors instead of the default ggplot palette.
race_colors <- c(
  "White" = "#4477AA",
  "Black" = "#EE6677",
  "Hispanic" = "#228833",
  "Asian" = "#CCBB44",
  "Native American" = "#66CCEE",
  "Other" = "#AA3377",
  "Unknown / not reported" = "#BBBBBB",
  "Other / uncategorized" = "#882255"
)

# Creating the bar chart.
ggplot(
  race_counts,
  aes(
    x = race_group,
    y = incidents,
    fill = race_group
  )) +
  geom_col(show.legend = TRUE) +
  coord_flip() +
  scale_fill_manual(
    values = race_colors,
    name = "Victim Recorded race"
  ) +
  labs(
    title = "Fatal Police Shooting Records by Reported Race",
    subtitle = "United States, supplied records dated 2015–2022",
    x = "Victim Recorded Race",
    y = "Number of records",
    caption = paste(
      "Source: supplied fatal-police-shootings-data.csv;",
      "associated with The Washington Post Fatal Force project."
    )) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title = element_text(face = "bold"),
    legend.position = "right",
    panel.grid.major.y = element_blank())

Visualization 2: Annual recorded incidents

This chart shows the number of records for each calendar year. Points show each annual total, and the line shows the fitted linear regression trend. The line is a simple summary and should not be interpreted as a causal explanation or a reliable forecast.

annual_counts <- shootings |>
  filter(!is.na(year)) |>
  count(year, name = "incidents") |>
  arrange(year)

ggplot(annual_counts, aes(x = year, y = incidents)) +
  geom_line(color = "#4477AA", linewidth = 0.5) +
  geom_point(aes(color = "Annual total"), size = 3) +
  geom_smooth(
    method = "lm", formula = y ~ x, se = TRUE,
    aes(color = "Linear regression"), linewidth = 1
  ) +
  scale_color_manual(
    name = "Series",
    values = c("Annual total" = "#CC6677", "Linear regression" = "#228833")
  ) +
  scale_x_continuous(breaks = annual_counts$year) +
  labs(
    title = "Fatal Police Shooting Records per Year",
    subtitle = "Annual counts and fitted linear trend",
    x = "Year",
    y = "Number of records",
    caption = "Source: supplied fatal-police-shootings-data.csv; associated with The Washington Post Fatal Force project."
  ) +
  theme_classic(base_size = 12) +
  theme(
    plot.title = element_text(face = "bold"),
    legend.position = "bottom"
  )

Linear regression: Annual count by year

This model uses the calendar year as the explanatory variable and the number of recorded incidents in that year as the response variable. The regression is calculated from the annual summary, so each observation in the model represents one year.

annual_model <- lm(incidents ~ year, data = annual_counts)

summary(annual_model)

Call:
lm(formula = incidents ~ year, data = annual_counts)

Residuals:
   Min     1Q Median     3Q    Max 
-22.58 -10.83  -4.75  10.48  32.51 

Coefficients:
              Estimate Std. Error t value Pr(>|t|)  
(Intercept) -15339.988   6581.498  -2.331   0.0586 .
year             8.095      3.261   2.483   0.0476 *
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 21.13 on 6 degrees of freedom
Multiple R-squared:  0.5067,    Adjusted R-squared:  0.4245 
F-statistic: 6.164 on 1 and 6 DF,  p-value: 0.04763
coef(annual_model)
  (Intercept)          year 
-15339.988095      8.095238 

For the supplied file, the fitted equation is approximately:

[ = -15339.99 + 8.10() ]

The slope is about 8.1 additional recorded incidents per year on average. The model’s (R^2) is approximately 0.507,which means that about 50.7% of the variation among the eight annual totals is associated with the linear relationship with year. The estimated slope has a p-value of about 0.048 in this model, but it uses only eight annual observations and does not account for other factors. However, the actual number of incidents may vary from year to year, so the regression line does not perfectly represent every year’s count.

Conclusion

The data set provides thousands of records for detailing fatal police shootings, including dates, locations, demographic fields, circumstances, and body-camera information. During cleaning, I converted the date column from text to a date format and extracted the year so the incidents could be summarized over time. I also created readable race labels from the source codes and grouped missing race values into an “Unknown / not reported” category. Rather than automatically deleting rows, I checked for missing values and then created a summary of all those that did. This is because there were missing values across multiple variables, instead of modifying the original data set and reducing the overall counts because of unrelated missing fields.

The first visualization compares the number of records in each race category and makes the size of the unknown category visible. These are raw counts, not population-adjusted rates, so they do not by themselves show an individual’s risk or explain differences between groups. The second visualization compares annual totals and adds a fitted regression line. The model summarizes whether the overall pattern rises or falls across the years, but annual variation and the small number of years limit how much can be concluded from a straight line. The data set ends on December 1, 2022, so 2022 may not represent a complete calendar year.

I would also like to explore geographic patterns by state and compare body-camera status or reported signs of mental illness across categories. Those comparisons would need careful handling of missing values and context. In particular, the records should not be treated as a complete census of every police encounter, and this descriptive analysis cannot establish the causes of the patterns.