For this lab, I continued working with Citi Bike trip data from New
York City. I used one file from January 2025, so these results represent
only part of the month’s trips, not all January
ridership. I wanted to explore how classic and electric bike use differs
between members and casual riders, whether the busiest stations show
similar patterns, and how ride duration varies by bike type. I also used
the opportunity to practice different ways of displaying relationships
with ggplot2.
My goal is to show how bike type and membership relate to ridership patterns and trip duration. These visualizations are intended for people interested in understanding Citi Bike use in New York City, including riders and transportation planners.
The data come from Citi Bike System Data. I first loaded the data and counted trips by membership category and bike type.
library(here)
library(tidyverse)
library(janitor)
library(patchwork)
janCitiBike <- read_csv(
here("data", "202501-citibike-tripdata_1.csv")
)
bikeTypes <- janCitiBike %>%
group_by(member_casual, rideable_type) %>%
summarise(total_rides = n(), .groups = "drop")
I started with a grouped bar chart to compare the number of classic and electric bike rides for casual riders and members. In this subset, electric bikes account for more rides in both categories, and members account for many more rides overall. Because the plot shows raw counts, it describes how often each bike type was used rather than the proportion of riders who prefer each type.
##First Plot
ggplot(bikeTypes,
aes(x = member_casual,
y = total_rides,
fill = rideable_type)) +
geom_col(position = "dodge") +
labs(
title = "Citi Bike Ridership by Membership and Bike Type",
subtitle = "Partial January 2025 Data",
x = "Rider Type",
y = "Number of Rides",
fill = "Bike Type",
caption = "Source: Citi Bike System Data"
) +
scale_x_discrete(
labels = c("casual" = "Casual Riders",
"member" = "Members")
) +
scale_fill_discrete(
labels = c("classic_bike" = "Classic Bike",
"electric_bike" = "Electric Bike")
) +
scale_y_continuous(
labels = scales::label_comma()
) +
theme_minimal()
Figure 1. Number of classic and electric Citi Bike rides by membership category in a partial January 2025 dataset. Electric bikes accounted for more rides in both membership categories.
##Save out the plot
ggsave(
here("output", "ridership_by_bike_type.png"),
width = 9,
height = 6,
dpi = 300
)
I also experimented with using facet_wrap() to separate
classic and electric bike ridership into two panels. While this approach
makes it easier to examine each bike type individually, I preferred the
original grouped bar chart because it allows for a more direct
comparison between bike types within each membership category.
ggplot(bikeTypes,
aes(x = member_casual,
y = total_rides,
fill = rideable_type)) +
geom_col() +
facet_wrap(
~rideable_type,
labeller = as_labeller(
c("classic_bike" = "Classic Bikes",
"electric_bike" = "Electric Bikes")
)
) +
labs(
title = "Citi Bike Ridership by Membership and Bike Type",
subtitle = "Partial January 2025 Data",
x = "Rider Type",
y = "Number of Rides",
caption = "Source: Citi Bike System Data"
) +
scale_x_discrete(
labels = c("casual" = "Casual Riders",
"member" = "Members")
) +
scale_fill_manual(
values = c("classic_bike" = "#F8766D",
"electric_bike" = "#00BFC4")
) +
scale_y_continuous(labels = scales::label_comma()) +
theme_minimal() +
theme(legend.position = "none")
Figure 2. Citi Bike ridership by membership category, separated into panels for classic and electric bikes. Members accounted for more rides than casual riders in both panels, while electric bikes had higher ridership overall.
Next, I focused on member rides and identified the ten starting stations with the most trips in this file. A horizontal bar chart makes the station names easier to read and allows the two bike types to be compared at each location. Electric bike rides outnumber classic bike rides at all ten stations, although the size of the difference varies. This is a comparison of trip counts at stations, not a map of their geographic locations.
##Second Plot
topStations <- janCitiBike %>%
filter(member_casual == "member",
!is.na(start_station_name),
start_station_name != "") %>%
count(start_station_name, sort = TRUE) %>%
slice_head(n = 10) %>%
select(start_station_name)
stationBikeTypes <- janCitiBike %>%
filter(member_casual == "member") %>%
semi_join(topStations, by = "start_station_name") %>%
count(start_station_name, rideable_type, name = "rides")
ggplot(stationBikeTypes,
aes(x = reorder(start_station_name, rides),
y = rides,
fill = rideable_type)) +
geom_col(position = "dodge") +
coord_flip() +
labs(
title = "Bike Type at the 10 Most Popular Starting Stations",
subtitle = "Citi Bike Members | Partial January 2025 Data",
x = "Starting Station",
y = "Number of Rides",
fill = "Bike Type",
caption = "Source: Citi Bike System Data"
) +
scale_fill_discrete(
labels = c("classic_bike" = "Classic Bike",
"electric_bike" = "Electric Bike")
) +
scale_y_continuous(labels = scales::label_comma()) +
theme_minimal()
Figure 3. Classic and electric bike rides at the 10 most popular starting stations among Citi Bike members. Electric bikes accounted for more rides at each station.
##Save out the plot
ggsave(
here("output", "top_10_stations.png"),
width = 11,
height = 7,
dpi = 300
)
I then used a scatterplot to look at whether stations with more classic bike rides also tend to have more electric bike rides. I included member starting stations with at least 50 rides in this subset. The points and fitted line suggest a positive relationship, although the spread increases at stations with higher trip counts. This does not necessarily mean that classic bike use causes electric bike use; both may be related to overall station activity.
##Third Plot
stationComparison <- janCitiBike %>%
filter(member_casual == "member",
!is.na(start_station_name),
start_station_name != "") %>%
count(start_station_name, rideable_type) %>%
pivot_wider(
names_from = rideable_type,
values_from = n,
values_fill = 0
) %>%
mutate(total_rides = classic_bike + electric_bike) %>%
filter(total_rides >= 50)
ggplot(stationComparison,
aes(x = classic_bike,
y = electric_bike)) +
geom_point(alpha = 0.5, color = "steelblue") +
geom_smooth(method = "lm", se = FALSE) +
labs(
title = "Relationship Between Classic and Electric Bike Use",
subtitle = "Citi Bike Members | Partial January 2025 Data",
x = "Number of Classic Bike Rides",
y = "Number of Electric Bike Rides",
caption = "Source: Citi Bike System Data"
) +
scale_x_continuous(labels = scales::label_comma()) +
scale_y_continuous(labels = scales::label_comma()) +
theme_minimal()
Figure 4. Relationship between classic and electric bike rides across starting stations with at least 50 member rides. Stations with more classic bike rides generally also had more electric bike rides.
##Save out the plot
ggsave(
here("output", "station_bike_comparison.png"),
width = 9,
height = 6,
dpi = 300
)
Finally, I calculated trip duration using the start and end timestamps. I excluded nonpositive durations and rides longer than 180 minutes before calculating the mean and median for each membership and bike type combination. I used a 180-minute cutoff to limit the influence of unusually long trips that may not reflect typical bike use. Comparing both statistics helps show how longer trips influence the average. Casual rides are longer than member rides in both categories. For members, the median durations for classic and electric bikes are nearly identical, even though the mean is slightly higher for classic bikes. These are durations, not distances traveled.
##trip duration information
rideDuration <- janCitiBike %>%
mutate(
started_at = as.POSIXct(started_at),
ended_at = as.POSIXct(ended_at),
duration_min = as.numeric(
difftime(ended_at, started_at, units = "mins")
)
) %>%
filter(duration_min > 0,
duration_min <= 180)
avgDuration <- rideDuration %>%
group_by(member_casual, rideable_type) %>%
summarise(
avg_minutes = mean(duration_min, na.rm = TRUE),
median_minutes = median(duration_min, na.rm = TRUE),
.groups = "drop"
)
avgDuration
## # A tibble: 4 × 4
## member_casual rideable_type avg_minutes median_minutes
## <chr> <chr> <dbl> <dbl>
## 1 casual classic_bike 16.3 11.9
## 2 casual electric_bike 12.6 8.69
## 3 member classic_bike 9.39 6.78
## 4 member electric_bike 8.89 6.89
I used patchwork to place the mean and median plots side
by side with a shared legend and matching y-axis scales. This makes the
differences between the two summaries easier to compare.
##Mean trip Duration
meanPlot <- ggplot(avgDuration,
aes(x = member_casual,
y = avg_minutes,
fill = rideable_type)) +
geom_col(position = "dodge") +
labs(
title = "Average Citi Bike Ride Duration by Bike Type",
subtitle = "Partial January 2025 Data | Rides up to 180 minutes",
x = "Rider Type",
y = "Average Ride Duration (minutes)",
fill = "Bike Type"
) +
scale_x_discrete(
labels = c("casual" = "Casual Riders",
"member" = "Members")
) +
scale_fill_discrete(
labels = c("classic_bike" = "Classic Bike",
"electric_bike" = "Electric Bike")
) +
theme_minimal()
##Median Trip Duration
medianPlot <- ggplot(avgDuration,
aes(x = member_casual,
y = median_minutes,
fill = rideable_type)) +
geom_col(position = "dodge") +
labs(
title = "Median Citi Bike Ride Duration by Bike Type",
subtitle = "Partial January 2025 Data | Rides up to 180 minutes",
x = "Rider Type",
y = "Median Ride Duration (minutes)",
fill = "Bike Type"
) +
scale_x_discrete(
labels = c("casual" = "Casual Riders",
"member" = "Members")
) +
scale_fill_discrete(
labels = c("classic_bike" = "Classic Bike",
"electric_bike" = "Electric Bike")
) +
theme_minimal()
#Put the 2 graphs together
durationComparison <- (
meanPlot +
labs(title = "Mean Ride Duration", subtitle = NULL) |
medianPlot +
labs(title = "Median Ride Duration", subtitle = NULL)
) +
plot_layout(guides = "collect") +
plot_annotation(
title = "Comparing Mean and Median Citi Bike Ride Durations",
subtitle = "Partial January 2025 Data | Rides up to 180 minutes",
caption = "Source: Citi Bike System Data"
) &
scale_y_continuous(limits = c(0, 20), breaks = seq(0, 20, 5)) &
theme(legend.position = "bottom")
durationComparison
Figure 5. Comparison of mean and median ride durations by membership and bike type. Mean durations were higher than median durations in all four groups, suggesting that longer rides influenced the averages. Rides longer than 180 minutes were excluded.
##Save out the plot
ggsave(
here("output", "duration_comparison.png"),
plot = durationComparison,
width = 12,
height = 6,
dpi = 300
)
These visualizations show different aspects of Citi Bike use within the January file I analyzed. Electric bikes appear more frequently in the ride counts, including at the busiest member starting stations, while ride duration tells a different story: casual rides are longer, and the difference between classic and electric bikes depends on whether I use the mean or median. The plots also gave me practice choosing chart types based on the question I wanted to answer, refining labels and legends, and combining figures to make comparisons easier. I also tried to apply some of the design principles from this week’s readings by using consistent colors for classic and electric bikes across figures, simplifying labels, and choosing graph types that make the comparisons easier to understand.
The analysis is limited to a partial January 2025 file and should not be treated as representative of all Citi Bike trips or of seasonal ridership. The duration results also depend on the decision to exclude trips longer than 180 minutes.
Citi Bike. (2025). Citi Bike system data. https://citibikenyc.com/system-data