install.packages(c("tigris", "sf", "plotly", "broom"))
#install.packages("tidycensus", dependencies = TRUE)Emergency Services Call Analysis in Buffalo, New York
Exploring the Correlation Between Mental Health–Related Emergency Calls and Median Household Income.
1. Introduction
Emergency services are essential in times of crisies, providing necessary response teams to areas in need with support from Police, Fire, and Medical professionals. Unfortunately high call volumes and low staffing can result in misguided directives and improper handling of sensitive situations. This report will provide further insight on call classification and distribution of mental health related calls within the City of Buffalo, including any potential correlation between median household income. The results of this analysis will assist in understanding the use of Buffalo’s resources and how socio-economic factors can be indicative of improper resource distribution and improved community development.
2. Materials and Methods
Overview: Import, clean and categorize required data from 2024 into Mental Health categories. Plot call types and call volumes. Run a regression analysis on specified mental health call types and median income values.
Data required for this analysis: - 911 call data provided by the City of Buffalo Police Department for all of 2024. Data can be found at ‘https://doi.org/10.6084/m9.figshare.30794246’ - Census tract data from ‘https://www.census.gov/data/’ using an API key from 2023, the most recent and compatible dataset. Must include median income data and total populaton data. - Zipcode boundaries from ‘https://data.buffalony.gov/’ using an API key.
Necessary steps: 1. Import necessary libraries and datasets for use. 2. Clean up imported data and complete pre-processing requirements. 3. Define mental health categories and assign them to the call data. 4. Create a summary table of call types and locations. 5. Plot a bar chart summary of call frequency 6. Map most frequent call types by zipcode 7. Map call volume by zip code 8. Create and run a regression model on call frequency versus median income. 9. Plot regression.
2.1 Data Sources
First, necessary libraries for data processing, retrieval and organization will be downloaded.
# Data processing libraries
library(dplyr)
library(tidyr)
library(readr)
# Mapping & Spatial Data
library(sf)
library(leaflet)
library(htmltools)
# Census & Geographical Data Retrieval
library(tigris)
library(tidycensus)
# Visualization
library(ggplot2)
library(plotly)
# Modeling and Statistical Output
library(broom)With these libraries installed, the 911 data can be imported from a CSV file saved within the GitHub data folder. Zipcode data can be imported by using an API through the OpenData Buffalo website. A similar process is used for the census data, using an API key and the TIGRIS library previously installed.
# Import 911 data CSV from github data folder
data1_911_url <- "https://raw.githubusercontent.com/sjdeck/geo511/refs/heads/master/data/c2024_data_all-1.csv"
data1_911 <- read_csv(data1_911_url)
# Import 911 data (second part) CSV from github data folder
data2_911_url <- "https://raw.githubusercontent.com/sjdeck/geo511/refs/heads/master/data/c2024_data_all-2.csv"
data2_911 <- read_csv(data2_911_url)
data_911 <- bind_rows(data1_911, data2_911)
# Import zipcode data from API
url_json <- "https://data.buffalony.gov/resource/qnyw-efar.geojson"
zipcode_json <- st_read(url_json, quiet = TRUE)
# Import Census data through API
# Set API key
census_api_key("d6bce03273517ee40d3077937c0680b32226e966", install = FALSE)
readRenviron("~/.Renviron")
# Download census data
census_data <- get_acs(
geography = "zcta",
variables = c(
median_income = "B19013_001",
total_pop = "B01003_001"),
year = 2023,
survey = "acs5",
geometry = FALSE
)2.2 Preprocessing and Data Preparation
Cleaning and Filtering 911 records
Now with all the data imported, data preprocessing measures are taken to ensure smooth transitioning and analysis. The original 911 data is not suitable for spatial analysis, so converting to an SF object will help with mapping and spatial analysis. Projection transformation on the zipcode and census data is also required to match the 911 data.
# Convert to an sf object to "enable standard spatial data handling, analysis, and visualization"
calls_sf <- st_as_sf(data_911, coords = c("Longitude", "Latitude"), crs = 4326, remove = FALSE)
# Transform zipcode data to match 911 data
zipcode_json <- st_transform(zipcode_json, st_crs(calls_sf))
# Join call data and zipcode data together
calls_sf1 <- st_intersection(calls_sf, zipcode_json %>% select(zcta5ce10))
# Clean census data, change CRS and pivot wider
census_clean <- census_data %>%
select(GEOID, variable, estimate, GEOID) %>%
pivot_wider(names_from = variable, values_from = estimate)Categorizing and assigning call descriptions
Paid Partnership for Buffalo requested a mental health call analysis for a potential pilot program, assisting areas with high mental health calls with resources other than police officers during emergencies that may not require or benefit from police presence. The following descriptions are the request calls included in the original analysis. Only 911 calls with the given descriptors are considered for this analysis.
# Categories given by PPG Buffalo
mental_health_desc <- c("LOUD NOISE", "NEIGHBOR TROUBLE","TRESPASSING","CUSTOMER TROUBLE",
"DOMESTIC TROUBLE","CHECK WELFARE","UNWELCOME GUEST","DRUNK","NARCOTICS","MISCELLANEOUS",
"ASSIST CITIZEN","PERSON SOLICITING", "JUVENILE TROUBLE","PROPERTY DISPUTE","MOTORIST STRANDED","LANDLORD TROUBLE","SUICIDE ATTEMPT","PERSON DOWN","PERSON SCREAMING","JUVENILE FOUND","INDECENT EXPOSURE","LABOR DISPUTE","PROSTITUTION","GAMBLING" )
# Create a new dataset with only mental health descriptors
mental_health_data <- calls_sf1 %>%
filter( DESCRIPTIO %in% mental_health_desc ) %>%
rename(
description = DESCRIPTIO,
date = X.DATEREPORT,
full_address = Buff_remov,
street_name = STRNAME,
zipcode = zcta5ce10
)2.3 Analytic Methods
2.3.1 Call Type Analysis
Categorize call type, map dominate calls, frequency bar chart
A summary table of each description and the frequency of each description is created. High call types can indicate areas that may lack the necessary services to assist in these issues, or limited resources for the community.
# Count each description and order by frequency
summary_table_desc <- mental_health_data %>%
count(description, sort = FALSE) %>% # counts and sorts descending by n
rename(Frequency = n)A frequency bar graph can best visualize what call descriptions occur frequently. However, this does not show where these calls occur. This bar graph will be saved in a variable for analysis.
# ggplot of the call frequency and description
descr_freq_bar <- ggplot(summary_table_desc, aes(x = factor(description), y = Frequency)) +
geom_bar(stat = "identity", fill = "steelblue") +
labs(
title = "Frequency of Mental Health Calls in Buffalo, New York (2024)",
caption = "Data from 2024",
x = "Call Description",
y = "Frequency"
) +
theme(axis.text.x = element_text(angle = 90, vjust = 0.5, hjust = 1))In order to map this data, a new dataset will be created organizing and summarizing the mental health data and dropping the geometry to perform some non-spatial operations. The geometry will be added back after to let us map the top call frequencies by Zipcode.
# Top call and description
top_calls <- mental_health_data %>%
st_drop_geometry() %>%
group_by(zipcode, description) %>%
summarise(n = n(), .groups = "drop_last") %>%
slice_max(order_by = n, n = 1, with_ties = FALSE)
# Join back for geometry zip data
top_calls_zip <- zipcode_json %>%
left_join(top_calls, by = c("zcta5ce10" = "zipcode")) %>%
select(zcta5ce10, n, description)With the new dataset, a map is created showing the most frequent mental health 911 calls in each zipcode. It is important to note that this data is not normalized. The map is then saved to a variable for later analysis.
# Color palette
pal <- colorFactor("viridis", domain = top_calls_zip$description)
# Leaflet map of Top calls by description
call_type_map <- leaflet(top_calls_zip) %>%
addProviderTiles("CartoDB.Positron") %>%
addPolygons(
fillColor = ~pal(description),
color = "black",
weight = 1,
fillOpacity = 0.8,
label = ~paste0("ZIP: ", zcta5ce10,
"<br>Description: ", description,
"<br>Count: ", n),
labelOptions = labelOptions(
style = list("font-size" = "12px"),
direction = "auto"
)
) %>%
addLegend(
pal = pal,
values = ~description,
title = "Most Frequent Call Type",
opacity = 1
)2.3.2 Call Frequency Analysis
Call volume per zip per 1,000 residents, heatmap/choropleth, identify high volume
A summary table of each description and the frequency within each zipcode is created. High call volume in specific zipcodes can indicate similar circumstances as having a high call type.
# Count each zipcode and order by frequency
summary_table_zip <- mental_health_data %>%
count(zipcode, sort = TRUE) %>% # count how many times each ZIP appears
rename(Frequency = n)A frequency bar graph is created to visualize where these calls occur frequently. However, this does not show where these calls occur. This bar graph will be saved in a variable for analysis.
# ggplot of the zipcode frequency and description
zipcode_freq_bar <- ggplot(summary_table_zip, aes(x = factor(zipcode), y = Frequency)) +
geom_bar(stat = "identity", fill = "steelblue") +
labs(
title = "Frequency of Mental Health Calls per Zipcode in Buffalo, New York (2024)",
caption = "Data from 2024",
x = "Zipcode",
y = "Frequency"
) +
theme(axis.text.x = element_text(angle = 90, vjust = 0.5, hjust = 1))As done previously, the geometry for the summary table will be dropped in order to perform non-spatial organization. This is done for the census data as well. Once organized, the geometry is added back while simultaneously merging the two tables together.
# Drop geometry on zip summary data
zip_frequency <- summary_table_zip %>%
st_drop_geometry() %>%
group_by(zipcode)
# Drop geometry on census data
census_drop <- census_clean %>%
st_drop_geometry()
# Merge with census data
zip_data <- zipcode_json %>%
left_join(zip_frequency, by = c("zcta5ce10" = "zipcode")) %>%
left_join(census_drop, by = c("geoid10" = "GEOID")) %>%
mutate(Frequency = replace_na(Frequency, 0))To normalize this data, a new column is created with the frequency / total population multiplied by 1000. This normalization allows for a standardized analysis to be done without population bias.
# Create normalization data for every 1000 people
zip_data <- zip_data %>%
mutate(calls_per_1000 = (Frequency / total_pop) * 1000)With this normalized data, a map can be created to show the zip codes in Buffalo with high call volumes per 1000 people. This map will be saved to a variable for analysis.
# Palette creation for color
pal <- colorNumeric("viridis", domain = zip_data$calls_per_1000)
# Normalize
normalize_map <- leaflet(zip_data) %>%
addProviderTiles("CartoDB.Positron") %>%
addPolygons(
fillColor = ~pal(calls_per_1000),
color = "black",
weight = 1,
fillOpacity = 0.8,
label = ~paste0(
"<strong>ZIP: </strong>", zcta5ce10, "<br>",
"<strong>Calls per 1,000: </strong>", round(calls_per_1000, 2), "<br>",
"<strong>Population: </strong>", total_pop
) %>% lapply(htmltools::HTML)
) %>%
addLegend(pal = pal, values = ~calls_per_1000, title = "Calls per 1,000 Residents")2.3.3 Income vs Call Activity Analysis
Regression model, map high call/low-income pattern
With the call descriptions and call volume information processed, a simple regression model can be run to compare how call frequency is impacted by median household income. To visualize the relationship, this will be visualized and saved in a variable.
# Run simple regression model
model <- lm(Frequency ~ median_income, data = zip_data)
# Visualize
regression_plot <- ggplot(zip_data, aes(x = median_income, y = Frequency)) +
geom_point() +
geom_smooth(method = "lm", se = TRUE, color = "blue") +
labs(
title = "Relationship Between 911 Call Volume and Median Household Income",
x = "Median Household Income ($)",
y = "Total 911 Calls"
) +
theme_minimal()3. Results
The first analysis in section 2.3.1 is on the call description frequency and what call types are common where. The bar graph that was created shows Domestic Trouble to be the highest frequency, with Check Welfare next. When plotted on a map, Domestic Trouble is a top call description for a majority of the Buffalo zip codes with high residential volume. Areas with low residential volume are higher in other description categories such as Assist Citizen or Trespassing.
ggplotly(descr_freq_bar)call_type_mapThe second analysis in section 2.3.2 highlights the areas that have a higher call frequency in each zipcode per 1000 residents. However, this map is misleading. This is because the zipcodes with higher call volumes have a much lower residency rate, due to its location being primarily business fronts. This can be shown specifically with zipcode 14203 that has 160.73 calls per 1000 people but only 2.526 residents, where as the 14215 zipcode has 39.52 calls per 1000 residents with 42,813 residents total. The bar graph created for this map shows the call frequency without normalization, indicating the high volume of calls in general.
ggplotly(zipcode_freq_bar)normalize_mapWith better understanding of the call distribution across Buffalo, how are these call volumes impacted by other socio-economic factors? By using a simple regression model, a better understanding of the relationship between median household income and the frequency of calls can be developed to understand how shared stressors of a community can impact mental well-being.
tidy(model)# A tibble: 2 × 5
term estimate std.error statistic p.value
<chr> <dbl> <dbl> <dbl> <dbl>
1 (Intercept) 7778. 1769. 4.40 0.000348
2 median_income -0.0788 0.0328 -2.40 0.0273
With the above table, it is determined that for every $1,000 increase in median income, call volume will decrease by roughly 78.8 calls. This can be visualized through the graph created earlier.
ggplotly(regression_plot)`geom_smooth()` using formula = 'y ~ x'
4. Conclusions
From the analysis, it is shown that areas with lower income have higher call volumes for mental health related emergencies, showcasing a decrease of 78.8 calls for every additional $1,000 in median income. Section 2.3.1 highlighted the type of calls that have high call volumes for the City of Buffalo, with domestic based trouble and residential welfare checks with the most. Section 2.3.2 shows which zipcodes have these high call volumes, with a majority of the calls coming from residential neighborhoods. However, non-residential areas have the highest normalized call volume despite total call quantity being average if not below average. Section 2.3.3 studies the relationship between these calls and the zipcodes median income, which indicates areas with lower income experience a higher call volume for mental health related issues. After this analysis, future works could explore the relationship between these higher calls and other socio-economic factors or demographic factors that could contribute to these mental health issues. A spatial regression or temporal regression would also be beneficial, further deepening the understanding between where these calls take place and when.
5. References
Spectrum News: “Buffalo Group looks to bring emergency responders to Erie County for nonviolent 911 calls” > News > News > Partnership for the Public Good (PPG) - Buffalo, NY. (n.d.). https://ppgbuffalo.org/news-and-events/news/article:11-08-2024-12-00am-spectrum-news-buffalo-group-looks-to-bring-emergency-responders-to-erie-county-for-nonviolent-911-calls/
Freedom of Information Requests (FOIL)NextRequest - modern FOIA & Public Records request software. (n.d.). https://cityofbuffalony.nextrequest.com/
Deck, Sydney (2025). City of Buffalo 911 Call Data. figshare. Dataset. https://doi.org/10.6084/m9.figshare.30794246
US Census Bureau. (2025, August 28). Data. Census.gov. https://www.census.gov/data/
Open Data Portal | OpenData Buffalo. (n.d.). Tyler Data & Insights. https://data.buffalony.gov/
Example Crime Map - https://www.arcgis.com/apps/mapviewer/index.html?layers=7405be0742a846ab94c4988ddfe6e581
Normalized Mental Health Calls Example - https://www.arcgis.com/apps/mapviewer/index.html?layers=c4645f67802b4e63bb1effa7f21d0036