Data-607–TidyVerse—Exploring Income, Poverty, and Commuting Patterns Across New York City

Bikash Bhowmik
14-Nov-2026

Introduction

New York City is made up of neighborhoods with different population sizes, income levels, commuting patterns, and economic conditions. Census-tract data provide a useful way to explore how these characteristics vary across the five boroughs.

This vignette demonstrates how to use the Tidyverse to import, inspect, clean, transform, summarize, and visualize a real-world dataset. The analysis focuses on three questions:

  1. How does reported household income vary across the five boroughs?
  2. Is there an association between tract-level poverty and income?
  3. How do average commute times differ across boroughs?

The goal is to demonstrate reproducible data-wrangling and visualization techniques. The analysis is descriptive: it does not establish that one characteristic causes another.

Dataset and source

The dataset contains demographic, economic, employment, and commuting information for New York City census tracts. It was originally distributed through Kaggle as New York City Census Data and is accessed here from a public GitHub copy of the CSV file.

The source file contains census-tract identifiers, county and borough names, total population, income measures, poverty measures, employment information, and commuting characteristics. The original example reports 2,167 rows and 36 columns. The code below checks the dimensions again when the file is loaded.

Unit of analysis: one row represents a census tract. Therefore, borough averages in this vignette are averages across tracts, not population-weighted estimates for all borough residents.

Learning objectives

After completing this vignette, a reader should be able to:

  • import a CSV file with readr::read_csv();
  • inspect a data frame with glimpse() and summarise();
  • identify missing values with is.na() and across();
  • select useful columns with select();
  • remove incomplete or invalid records with filter() and drop_na();
  • create derived variables with mutate() and case_when();
  • summarize observations by group with group_by() and summarise();
  • sort results with arrange() and slice_*(); and
  • create plots with ggplot2.

Install and load the packages

The tidyverse package loads a collection of packages designed to work together for data science. This vignette mainly uses readr, dplyr, tidyr, ggplot2, and forcats.

Code
library(tidyverse)

Import the dataset

read_csv() reads a comma-separated file and returns a tibble. A tibble is a modern form of data frame that prints useful information without displaying every row and column.

Code
data_url <- paste0("https://raw.githubusercontent.com/BIKASHBHOWMIK15/Data-607/main/Tidyverse/nyc_census_tracts.csv")

nyc_raw <- read_csv(data_url, show_col_types = FALSE)

# Check the number of rows and columns.
dim(nyc_raw)
#> [1] 2167   36
Code
# Display the column names.
names(nyc_raw)
#>  [1] "CensusTract"     "County"          "Borough"         "TotalPop"       
#>  [5] "Men"             "Women"           "Hispanic"        "White"          
#>  [9] "Black"           "Native"          "Asian"           "Citizen"        
#> [13] "Income"          "IncomeErr"       "IncomePerCap"    "IncomePerCapErr"
#> [17] "Poverty"         "ChildPoverty"    "Professional"    "Service"        
#> [21] "Office"          "Construction"    "Production"      "Drive"          
#> [25] "Carpool"         "Transit"         "Walk"            "OtherTransp"    
#> [29] "WorkAtHome"      "MeanCommute"     "Employed"        "PrivateWork"    
#> [33] "PublicWork"      "SelfEmployed"    "FamilyWork"      "Unemployment"
Code
# Inspect variable types and example values.
glimpse(nyc_raw)
#> Rows: 2,167
#> Columns: 36
#> $ CensusTract     <dbl> 36005000100, 36005000200, 36005000400, 36005001600, 36…
#> $ County          <chr> "Bronx", "Bronx", "Bronx", "Bronx", "Bronx", "Bronx", …
#> $ Borough         <chr> "Bronx", "Bronx", "Bronx", "Bronx", "Bronx", "Bronx", …
#> $ TotalPop        <dbl> 7703, 5403, 5915, 5879, 2591, 8516, 4774, 150, 5355, 3…
#> $ Men             <dbl> 7133, 2659, 2896, 2558, 1206, 3301, 2130, 109, 2338, 1…
#> $ Women           <dbl> 570, 2744, 3019, 3321, 1385, 5215, 2644, 41, 3017, 164…
#> $ Hispanic        <dbl> 29.9, 75.8, 62.7, 65.1, 55.4, 61.1, 62.3, 0.0, 76.5, 6…
#> $ White           <dbl> 6.1, 2.3, 3.6, 1.6, 9.0, 1.6, 0.2, 52.0, 1.5, 0.0, 1.6…
#> $ Black           <dbl> 60.9, 16.0, 30.7, 32.4, 29.0, 31.1, 36.5, 48.0, 18.9, …
#> $ Native          <dbl> 0.2, 0.0, 0.0, 0.0, 0.0, 0.3, 1.0, 0.0, 0.0, 0.0, 0.0,…
#> $ Asian           <dbl> 1.6, 4.2, 0.3, 0.0, 2.1, 3.3, 0.0, 0.0, 3.0, 0.0, 0.0,…
#> $ Citizen         <dbl> 6476, 3639, 4100, 3536, 1557, 5436, 3056, 41, 2509, 14…
#> $ Income          <dbl> NA, 72034, 74836, 32312, 37936, 18086, 14479, NA, 1722…
#> $ IncomeErr       <dbl> NA, 13991, 8407, 6859, 3771, 3694, 1901, NA, 6097, 522…
#> $ IncomePerCap    <dbl> 2440, 22180, 27700, 17526, 17986, 12023, 9781, 49365, …
#> $ IncomePerCapErr <dbl> 373, 2206, 2449, 2945, 2692, 2346, 1460, 2072, 1714, 1…
#> $ Poverty         <dbl> NA, 20.0, 13.2, 26.3, 37.1, 53.2, 54.2, 0.0, 50.5, 52.…
#> $ ChildPoverty    <dbl> NA, 20.7, 23.6, 35.9, 31.5, 67.7, 68.3, 0.0, 62.4, 64.…
#> $ Professional    <dbl> NA, 28.7, 32.2, 19.1, 35.4, 14.5, 20.8, 63.7, 15.9, 19…
#> $ Service         <dbl> NA, 17.1, 23.4, 36.1, 20.9, 41.1, 36.0, 36.3, 36.6, 35…
#> $ Office          <dbl> NA, 23.9, 24.9, 26.2, 26.2, 16.7, 33.7, 0.0, 24.2, 25.…
#> $ Construction    <dbl> NA, 8.0, 9.0, 4.9, 6.6, 7.1, 3.4, 0.0, 8.6, 4.9, 1.7, …
#> $ Production      <dbl> NA, 22.3, 10.5, 13.8, 11.0, 20.6, 6.1, 0.0, 14.7, 13.5…
#> $ Drive           <dbl> NA, 44.8, 41.3, 37.2, 19.2, 19.6, 5.9, 0.0, 12.6, 14.0…
#> $ Carpool         <dbl> NA, 13.7, 10.0, 5.3, 5.3, 7.0, 0.0, 0.0, 2.8, 1.7, 3.0…
#> $ Transit         <dbl> NA, 38.6, 44.6, 45.5, 63.9, 68.2, 74.5, 100.0, 62.5, 6…
#> $ Walk            <dbl> NA, 2.9, 1.4, 8.6, 3.0, 4.3, 14.0, 0.0, 17.7, 18.0, 7.…
#> $ OtherTransp     <dbl> NA, 0.0, 0.5, 1.6, 2.4, 1.0, 1.5, 0.0, 1.8, 0.0, 0.7, …
#> $ WorkAtHome      <dbl> NA, 0.0, 2.1, 1.7, 6.2, 0.0, 4.1, 0.0, 2.7, 1.6, 0.5, …
#> $ MeanCommute     <dbl> NA, 43.0, 45.0, 38.8, 45.4, 46.0, 42.7, NA, 35.5, 42.8…
#> $ Employed        <dbl> 0, 2308, 2675, 2120, 1083, 2508, 1191, 113, 1691, 1102…
#> $ PrivateWork     <dbl> NA, 80.8, 71.7, 75.0, 76.8, 71.0, 74.2, 62.8, 85.1, 86…
#> $ PublicWork      <dbl> NA, 16.2, 25.3, 21.3, 15.5, 21.3, 16.1, 37.2, 8.3, 8.5…
#> $ SelfEmployed    <dbl> NA, 2.9, 2.5, 3.8, 7.7, 7.7, 9.7, 0.0, 6.1, 4.5, 11.0,…
#> $ FamilyWork      <dbl> NA, 0.0, 0.6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, …
#> $ Unemployment    <dbl> NA, 7.7, 9.5, 8.7, 19.2, 17.2, 18.9, 0.0, 9.4, 15.2, 1…

The original dataset contains 2,167 census-tract records and 36 variables. The dim() and glimpse() commands let us verify the structure rather than assume that the downloaded file has not changed.

Examine missing values

Missing data can affect summaries and plots. Before cleaning the dataset, it is useful to determine which columns contain missing values.

The code below combines summarise(), across(), and pivot_longer():

  • summarise() creates a one-row summary;
  • across() applies the same calculation to every column;
  • is.na() identifies missing values; and
  • pivot_longer() changes the summary from a wide layout to a tidy two-column layout.
Code
missing_summary <- nyc_raw %>%
  summarise(
    across(
      everything(),
      ~ sum(is.na(.x))
    )
  ) %>%
  pivot_longer(
    cols = everything(),
    names_to = "variable",
    values_to = "missing_count"
  ) %>%
  mutate(
    missing_percent = round(
      100 * missing_count / nrow(nyc_raw),
      2
    )
  ) %>%
  filter(missing_count > 0) %>%
  arrange(desc(missing_count))

missing_summary
#> # A tibble: 28 × 3
#>    variable        missing_count missing_percent
#>    <chr>                   <int>           <dbl>
#>  1 Income                     66            3.05
#>  2 IncomeErr                  66            3.05
#>  3 MeanCommute                61            2.81
#>  4 ChildPoverty               60            2.77
#>  5 IncomePerCap               46            2.12
#>  6 IncomePerCapErr            46            2.12
#>  7 Professional               43            1.98
#>  8 Service                    43            1.98
#>  9 Office                     43            1.98
#> 10 Construction               43            1.98
#> # ℹ 18 more rows

This table reports the number and percentage of missing values for each affected variable. It helps identify which fields need attention before analysis.

Select and clean the variables

The analysis does not require all 36 columns. select() keeps only the variables needed for the research questions. filter() removes records without the borough, income, or population information required for the main analysis.

The income and population fields are also checked for positive values. The selected poverty and commute fields are retained even if they contain missing values; they will be handled when the relevant analysis is performed.

Code
nyc <- nyc_raw %>%
  select(
    CensusTract,
    County,
    Borough,
    TotalPop,
    Income,
    IncomePerCap,
    Poverty,
    ChildPoverty,
    MeanCommute,
    Transit,
    Walk,
    Drive,
    Unemployment
  ) %>%
  filter(
    !is.na(Borough),
    !is.na(Income),
    !is.na(TotalPop),
    Income > 0,
    TotalPop > 0
  ) %>%
  mutate(
    Borough = as.factor(Borough)
  )

# Confirm the cleaned data structure.
glimpse(nyc)
#> Rows: 2,101
#> Columns: 13
#> $ CensusTract  <dbl> 36005000200, 36005000400, 36005001600, 36005001900, 36005…
#> $ County       <chr> "Bronx", "Bronx", "Bronx", "Bronx", "Bronx", "Bronx", "Br…
#> $ Borough      <fct> Bronx, Bronx, Bronx, Bronx, Bronx, Bronx, Bronx, Bronx, B…
#> $ TotalPop     <dbl> 5403, 5915, 5879, 2591, 8516, 4774, 5355, 3016, 4778, 529…
#> $ Income       <dbl> 72034, 74836, 32312, 37936, 18086, 14479, 17226, 20153, 1…
#> $ IncomePerCap <dbl> 22180, 27700, 17526, 17986, 12023, 9781, 11493, 10317, 89…
#> $ Poverty      <dbl> 20.0, 13.2, 26.3, 37.1, 53.2, 54.2, 50.5, 52.6, 52.7, 8.5…
#> $ ChildPoverty <dbl> 20.7, 23.6, 35.9, 31.5, 67.7, 68.3, 62.4, 64.9, 63.1, 6.5…
#> $ MeanCommute  <dbl> 43.0, 45.0, 38.8, 45.4, 46.0, 42.7, 35.5, 42.8, 44.0, 47.…
#> $ Transit      <dbl> 38.6, 44.6, 45.5, 63.9, 68.2, 74.5, 62.5, 64.7, 83.6, 48.…
#> $ Walk         <dbl> 2.9, 1.4, 8.6, 3.0, 4.3, 14.0, 17.7, 18.0, 7.1, 2.0, 14.6…
#> $ Drive        <dbl> 44.8, 41.3, 37.2, 19.2, 19.6, 5.9, 12.6, 14.0, 5.1, 42.8,…
#> $ Unemployment <dbl> 7.7, 9.5, 8.7, 19.2, 17.2, 18.9, 9.4, 15.2, 10.6, 12.8, 6…
Code
# Count the records retained for each borough.
nyc %>%
  count(Borough, name = "tract_count") %>%
  arrange(desc(tract_count))
#> # A tibble: 5 × 2
#>   Borough       tract_count
#>   <fct>               <int>
#> 1 Brooklyn              748
#> 2 Queens                642
#> 3 Bronx                 329
#> 4 Manhattan             275
#> 5 Staten Island         107

This cleaning step creates an analysis dataset containing records with the minimum information needed for the income and population comparisons. It does not remove a tract merely because a secondary variable, such as commute time, is missing.

Create a poverty category with mutate() and case_when()

A numeric poverty percentage can be easier to interpret when grouped into meaningful categories. mutate() creates a new column, while case_when() assigns a label based on conditions.

The categories below are descriptive groupings selected for this example; they are not official government classifications.

Code
nyc <- nyc %>%
  mutate(
    PovertyGroup = case_when(
      is.na(Poverty) ~ "Not reported",
      Poverty < 10 ~ "Below 10%",
      Poverty < 20 ~ "10% to below 20%",
      TRUE ~ "20% or higher"
    ),
    PovertyGroup = factor(
      PovertyGroup,
      levels = c(
        "Below 10%",
        "10% to below 20%",
        "20% or higher",
        "Not reported"
      )
    )
  )

nyc %>%
  count(PovertyGroup, name = "tract_count") %>%
  arrange(PovertyGroup)
#> # A tibble: 3 × 2
#>   PovertyGroup     tract_count
#>   <fct>                  <int>
#> 1 Below 10%                571
#> 2 10% to below 20%         675
#> 3 20% or higher            855

The new PovertyGroup variable can be used to compare income across broad poverty categories. Keeping missing poverty values as "Not reported" also makes the missing-data category visible rather than silently treating it as zero.

Summarize income and other characteristics by borough

group_by() divides the data into borough groups. summarise() then calculates statistics within each group. The na.rm = TRUE argument tells the summary functions to ignore missing values for the particular variable being summarized.

The income statistics below describe the distribution of census-tract income values within each borough. They should not be interpreted as population-weighted borough income estimates.

Code
borough_summary <- nyc %>%
  group_by(Borough) %>%
  summarise(
    census_tracts = n(),
    mean_tract_income = mean(Income, na.rm = TRUE),
    median_tract_income = median(Income, na.rm = TRUE),
    mean_population = mean(TotalPop, na.rm = TRUE),
    mean_poverty_percent = mean(Poverty, na.rm = TRUE),
    mean_commute_minutes = mean(MeanCommute, na.rm = TRUE),
    .groups = "drop"
  ) %>%
  arrange(desc(median_tract_income))

borough_summary
#> # A tibble: 5 × 7
#>   Borough    census_tracts mean_tract_income median_tract_income mean_population
#>   <fct>              <int>             <dbl>               <dbl>           <dbl>
#> 1 Manhattan            275            83352.              81250            5875.
#> 2 Staten Is…           107            72021.              75167            4415.
#> 3 Queens               642            62337.              60629            3583.
#> 4 Brooklyn             748            54265.              48398.           3467.
#> 5 Bronx                329            39311.              33365            4312.
#> # ℹ 2 more variables: mean_poverty_percent <dbl>, mean_commute_minutes <dbl>

The resulting table makes it possible to compare the boroughs using the same set of summary measures. The median is included because it is less sensitive than the mean to unusually high or low tract-level income values.

Identify the highest- and lowest-income census tracts

arrange() sorts rows, and slice_head() and slice_tail() select records from the sorted result. The following code identifies a small number of census tracts at each end of the income distribution.

Code
library(dplyr)
library(knitr)
library(kableExtra)

highest_income_tracts <- nyc %>%
  arrange(desc(Income)) %>%
  select(
    CensusTract,
    County,
    Borough,
    TotalPop,
    Income,
    Poverty
  ) %>%
  slice_head(n = 10)

lowest_income_tracts <- nyc %>%
  arrange(Income) %>%
  select(
    CensusTract,
    County,
    Borough,
    TotalPop,
    Income,
    Poverty
  ) %>%
  slice_head(n = 10)

highest_income_tracts %>%
  mutate(
    Income = scales::dollar(Income),
    Poverty = paste0(Poverty, "%")
  ) %>%
  kable(
    caption = "Top 10 NYC Census Tracts by Median Household Income",
    col.names = c(
      "Census Tract",
      "County",
      "Borough",
      "Population",
      "Median Household Income",
      "Poverty Rate"
    ),
    align = c("l", "l", "l", "r", "r", "r"),
    format.args = list(big.mark = ",")
  ) %>%
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed", "responsive"),
    full_width = FALSE,
    position = "center"
  )
Top 10 NYC Census Tracts by Median Household Income
Census Tract County Borough Population Median Household Income Poverty Rate
36,061,013,000 New York Manhattan 3,277 $244,375 7.1%
36,061,014,200 New York Manhattan 4,467 $235,455 5.9%
36,061,016,001 New York Manhattan 3,861 $222,222 2%
36,047,002,100 Kings Brooklyn 4,295 $208,036 7.6%
36,061,011,401 New York Manhattan 1,276 $197,500 3.2%
36,061,011,202 New York Manhattan 287 $192,857 4.5%
36,061,006,900 New York Manhattan 2,604 $183,750 7.4%
36,061,003,100 New York Manhattan 2,673 $183,500 13.1%
36,061,031,703 New York Manhattan 8,875 $182,401 4.3%
36,061,012,800 New York Manhattan 6,226 $175,259 4.5%
Code
lowest_income_tracts %>%
  mutate(
    Income = scales::dollar(Income),
    Poverty = paste0(Poverty, "%")
  ) %>%
  kable(
    caption = "Bottom 10 NYC Census Tracts by Median Household Income",
    col.names = c(
      "Census Tract",
      "County",
      "Borough",
      "Population",
      "Median Household Income",
      "Poverty Rate"
    ),
    align = c("l", "l", "l", "r", "r", "r"),
    format.args = list(big.mark = ",")
  ) %>%
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed", "responsive"),
    full_width = FALSE,
    position = "center"
  )
Bottom 10 NYC Census Tracts by Median Household Income
Census Tract County Borough Population Median Household Income Poverty Rate
36,047,080,800 Kings Brooklyn 1,256 $9,829 71.4%
36,047,091,000 Kings Brooklyn 6,094 $10,746 67.3%
36,047,035,200 Kings Brooklyn 1,035 $11,011 51.6%
36,061,011,900 New York Manhattan 1,068 $11,671 55.5%
36,005,014,300 Bronx Bronx 968 $12,000 52.2%
36,005,016,100 Bronx Bronx 4,140 $12,467 51.6%
36,047,090,800 Kings Brooklyn 5,679 $12,863 62.1%
36,047,008,500 Kings Brooklyn 6,916 $14,000 52.7%
36,085,013,301 Richmond Staten Island 1,531 $14,030 68.3%
36,005,014,701 Bronx Bronx 7,023 $14,334 61%

These results show the range of tract-level income values in the cleaned dataset. They are useful for checking whether a small number of unusually high or low observations may influence the overall distribution.

Visualize the income distribution

A histogram shows how frequently income values occur across the census tracts. The vertical line marks the median tract-level income in the cleaned dataset.

ggplot() defines the data and aesthetic mapping, while geom_histogram() draws the bars. geom_vline() adds the median reference line.

Code
overall_median_income <- median(nyc$Income, na.rm = TRUE)

ggplot(nyc, aes(x = Income)) +
  geom_histogram(
    bins = 40,
    fill = "steelblue",
    color = "white"
  ) +
  geom_vline(
    xintercept = overall_median_income,
    color = "red",
    linewidth = 1,
    linetype = "dashed"
  ) +
  labs(
    title = "Distribution of Census-Tract Income in New York City",
    subtitle = "The dashed line represents the median across included tracts",
    x = "Reported income (USD)",
    y = "Number of census tracts",
    caption = "Source: New York City Census Data; see dataset links above."
  ) +
  theme_minimal()

The histogram helps show whether tract-level income values are concentrated in a particular range or whether the distribution has a long tail. The median line provides a reference point for the distribution.

Compare income across the five boroughs

A boxplot displays the median, interquartile range, and potential outlying values for each borough. It is useful for comparing the spread of tract-level income values, rather than comparing only one average.

fct_reorder() orders the borough labels by their median income, making the chart easier to read.

Code
nyc %>%
  mutate(
    Borough = fct_reorder(Borough, Income, .fun = median, na.rm = TRUE)
  ) %>%
  ggplot(aes(x = Borough, y = Income)) +
  geom_boxplot(
    fill = "skyblue",
    color = "gray25",
    outlier.alpha = 0.35
  ) +
  coord_flip() +
  scale_y_continuous(labels = scales::label_dollar()) +
  labs(
    title = "Census-Tract Income Distribution by Borough",
    x = "Borough",
    y = "Reported income (USD)",
    caption = "Each observation represents a census tract."
  ) +
  theme_minimal()

The boxplots show both differences in typical income and differences in the amount of variation within each borough. Overlapping boxes indicate that the borough distributions are not completely separated.

Explore the relationship between poverty and income

A scatterplot can help determine whether census tracts with higher poverty percentages tend to have lower reported income. Each point represents one tract.

geom_smooth(method = "lm") adds a fitted linear trend line. This line summarizes an association in the observed data; it does not establish a causal relationship.

Code
poverty_income_data <- nyc %>%
  drop_na(Poverty, Income)

ggplot(
  poverty_income_data,
  aes(x = Poverty, y = Income)
) +
  geom_point(
    alpha = 0.45,
    color = "steelblue"
  ) +
  geom_smooth(
    method = "lm",
    se = TRUE,
    color = "darkred"
  ) +
  scale_y_continuous(labels = scales::label_dollar()) +
  labs(
    title = "Census-Tract Poverty and Income",
    subtitle = "Each point represents a tract with both measures available",
    x = "Population below poverty level (%)",
    y = "Reported income (USD)",
    caption = "The fitted line describes association, not causation."
  ) +
  theme_minimal()

The direction and spread of the points provide a visual indication of whether poverty and income are associated. The width of the scatter also reminds us that poverty alone does not explain all differences in tract-level income.

Compare average commute time by borough

The dataset also contains MeanCommute, which records mean commute time in minutes. This section uses drop_na() to include only tracts with a reported commute-time value.

Code
commute_summary <- nyc %>%
  drop_na(MeanCommute) %>%
  group_by(Borough) %>%
  summarise(
    mean_commute_minutes = mean(MeanCommute),
    median_commute_minutes = median(MeanCommute),
    tracts_with_commute_data = n(),
    .groups = "drop"
  ) %>%
  arrange(desc(mean_commute_minutes))

commute_summary %>%
  mutate(
    mean_commute_minutes = paste0(
      round(mean_commute_minutes, 1), " minutes"
    ),
    median_commute_minutes = paste0(
      round(median_commute_minutes, 1), " minutes"
    ),
    tracts_with_commute_data = scales::comma(
      tracts_with_commute_data
    )
  ) %>%
  kable(
    caption = "Average and Median Commute Time by NYC Borough",
    col.names = c(
      "Borough",
      "Mean Commute Time",
      "Median Commute Time",
      "Number of Census Tracts"
    ),
    align = c("l", "r", "r", "r")
  ) %>%
  kable_styling(
    bootstrap_options = c(
      "striped",
      "hover",
      "condensed",
      "responsive"
    ),
    full_width = FALSE,
    position = "center"
  )
Average and Median Commute Time by NYC Borough
Borough Mean Commute Time Median Commute Time Number of Census Tracts
Queens 42.9 minutes 42.4 minutes 641
Bronx 42.6 minutes 42.6 minutes 327
Staten Island 42.2 minutes 41.9 minutes 107
Brooklyn 41.9 minutes 42 minutes 748
Manhattan 30.8 minutes 30.3 minutes 275

The next plot compares the distribution of tract-level commute times across boroughs.

Code
nyc %>%
  drop_na(MeanCommute) %>%
  mutate(
    Borough = fct_reorder(
      Borough,
      MeanCommute,
      .fun = median
    )
  ) %>%
  ggplot(aes(x = Borough, y = MeanCommute)) +
  geom_boxplot(
    fill = "tan",
    color = "gray25"
  ) +
  coord_flip() +
  labs(
    title = "Mean Commute Time by Borough",
    x = "Borough",
    y = "Mean commute time (minutes)",
    caption = "Each observation represents a census tract with commute data."
  ) +
  theme_minimal()

The chart can reveal differences in typical commute time and variation across tracts within each borough. It does not identify the transportation mode or explain why commute times differ.

Reshape borough summaries with pivot_longer()

The pivot_longer() function converts selected summary columns into a long format. This is useful when several measures need to be displayed using the same plotting structure.

The measures in this example have different units, so the plot uses separate panels with independent y-axis scales.

Code
borough_summary %>%
  select(
    Borough,
    median_tract_income,
    mean_poverty_percent,
    mean_commute_minutes
  ) %>%
  mutate(
    median_tract_income = scales::dollar(median_tract_income),
    mean_poverty_percent = paste0(
      round(mean_poverty_percent, 1), "%"
    ),
    mean_commute_minutes = paste0(
      round(mean_commute_minutes, 1), " minutes"
    )
  ) %>%
  kable(
    caption = "Comparison of Income, Poverty, and Commuting Across NYC Boroughs",
    col.names = c(
      "Borough",
      "Median Household Income",
      "Average Poverty Rate",
      "Average Commute Time"
    ),
    align = c("l", "r", "r", "r")
  ) %>%
  kable_styling(
    bootstrap_options = c("striped", "hover", "condensed", "responsive"),
    full_width = FALSE,
    position = "center"
  )
Comparison of Income, Poverty, and Commuting Across NYC Boroughs
Borough Median Household Income Average Poverty Rate Average Commute Time
Manhattan $81,250.00 17.6% 30.8 minutes
Staten Island $75,167.00 13.3% 42.2 minutes
Queens $60,629.00 14% 42.9 minutes
Brooklyn $48,398.50 21.6% 41.9 minutes
Bronx $33,365.00 29.4% 42.6 minutes
Code
borough_summary_long <- borough_summary %>%
  select(
    Borough,
    median_tract_income,
    mean_poverty_percent,
    mean_commute_minutes
  ) %>%
  pivot_longer(
    cols = -Borough,
    names_to = "measure",
    values_to = "value"
  ) %>%
  mutate(
    measure = recode(
      measure,
      median_tract_income = "Median Household Income ($)",
      mean_poverty_percent = "Average Poverty Rate (%)",
      mean_commute_minutes = "Average Commute Time (Minutes)"
    )
  )

ggplot(
  borough_summary_long,
  aes(
    x = fct_reorder(Borough, value),
    y = value
  )
) +
  geom_col(fill = "cadetblue") +
  coord_flip() +
  facet_wrap(
    ~ measure,
    scales = "free_x"
  ) +
  labs(
    title = "Selected Census-Tract Measures by Borough",
    subtitle = "Comparing income, poverty, and commuting patterns across NYC",
    x = "Borough",
    y = "Value"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(face = "bold", size = 15),
    plot.subtitle = element_text(size = 11),
    strip.text = element_text(face = "bold", size = 11),
    axis.text = element_text(size = 10)
  )

This example demonstrates how pivot_longer() makes a wide summary table suitable for faceted plotting. Because income, poverty, and commute time use different units, each panel has its own scale and should be interpreted separately.

Key findings and interpretation

Use the summary tables and plots generated above to describe the patterns in the downloaded data. The following points are intended as an interpretation guide; the actual values should be read from the knitted output.

  • Income distribution: The histogram shows the spread of reported income across the census tracts included in the analysis. Compare the median reference line with the shape of the distribution.
  • Differences by borough: The borough summary and boxplot show how the median and spread of tract-level income vary across the five boroughs.
  • Poverty and income: The scatterplot and fitted trend line help assess whether higher tract-level poverty is associated with lower reported income in this dataset.
  • Commute time: The commute-time table and boxplot show how average tract-level commute times vary across boroughs.
  • Variation within boroughs: The boxplots show that a borough-level summary does not capture every difference among its census tracts.

These findings are descriptive and should not be interpreted as causal. The dataset represents census tracts, not individual households, and the borough-level summaries are not population-weighted estimates. Other variables and data from different years could produce different patterns.

Conclusion

This vignette demonstrated a practical Tidyverse workflow using New York City census-tract data. The analysis imported a CSV file, inspected its structure, measured missingness, selected relevant columns, created a poverty category, grouped observations by borough, calculated descriptive summaries, and created visualizations.

The examples highlighted several useful functions:

  • readr: read_csv()
  • dplyr: select(), filter(), mutate(), case_when(), group_by(), summarise(), arrange(), count(), drop_na(), and slice_head()
  • tidyr: pivot_longer()
  • forcats: fct_reorder()
  • ggplot2: ggplot(), aes(), geom_histogram(), geom_vline(), geom_boxplot(), geom_point(), geom_smooth(), geom_col(), facet_wrap(), and coord_flip()

Together, these functions make it possible to create a readable and reproducible data-analysis workflow. The same approach can be adapted to other public datasets and research questions.

References

  1. Kaggle. New York City Census Data. https://www.kaggle.com/datasets/muonneutrino/new-york-city-census-data
  2. Public CSV copy used for this vignette. https://raw.githubusercontent.com/BIKASHBHOWMIK15/Data-607/main/Tidyverse/nyc_census_tracts.csv
  3. The Tidyverse. Learn the Tidyverse. https://www.tidyverse.org/learn/