Exploring San Francisco Bay Area’s Bike Share System

[This article was first published on R Programming – DataScience+, and kindly contributed to R-bloggers]. (You can report issue about the content on this page here)
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

Congested streets and slow-crawling traffic are a fact of life in many metropolitan areas, such as New York City, Los Angeles, and Chicago. Bike sharing is an innovative solution for such problems, and it works by dispersing a large fleet of publicly-available bikes throughout crowded cities for personal transport.

Implemented in 2013, Ford GoBike is the first bike-sharing system introduced in the US West Coast. Its 540 stations and 7,000 bikes sprawl across five cities in San Francisco Bay Area. A docked bike can be checked out at any station and must be returned to a station when the trip is complete. As an avid GoBike user and a data fanatic, I am excited to explore, using R, the trip data that has been collected in 2018.


  • The number of rides per month more than doubled from January to June of 2018.
  • Monthly and yearly subscribers have completed more than five times the number of rides compared to customers who hold single-use or day-ride passes.
  • The ratio of male to female users is 3:1.
  • The median age of users is 35 with a standard deviation of 10.5.
  • More than 90% of the rides are under 30 minutes.
  • The peak hours across all rides in 2018 are 8 AM and 5 PM, which align with normal working hours.
  • The bike share system in San Francisco is popular among those who commute from outside of the city for work.

Data Acquisition

First, I downloaded the system data from the Ford GoBike website and saved them in my working directory. Currently, eight data files are available online, a collective file for 2017 and one for each month of 2018 up to August. In this analysis, I will solely focus on data that has been generated so far in 2018.

# List of data file names
file_names = list.files(pattern = ".csv")
<em>## [1] "201801-fordgobike-tripdata.csv" "201802-fordgobike-tripdata.csv"
## [3] "201803-fordgobike-tripdata.csv" "201804-fordgobike-tripdata.csv"
## [5] "201805-fordgobike-tripdata.csv" "201806-fordgobike-tripdata.csv"
## [7] "201807-fordgobike-tripdata.csv" "201808-fordgobike-tripdata.csv"
<p>To read the files into R, I iterate through <code>file_names</code> using the <code>lapply</code> function:</p>
<pre><code class="r"># Read data (this will take a second)
df_list = lapply(file_names,
                 function(x) read.csv(x, stringsAsFactors = FALSE))

# Assign names to data frames in the list
names(df_list) = file_names

Data Organization and Cleaning

Aggregate Data into a Single Data Frame

Let's look at the headers of a data frame in df_list:

# Extract column names from the first data frame
<em>##  [1] "duration_sec"            "start_time"             
##  [3] "end_time"                "start_station_id"       
##  [5] "start_station_name"      "start_station_latitude" 
##  [7] "start_station_longitude" "end_station_id"         
##  [9] "end_station_name"        "end_station_latitude"   
## [11] "end_station_longitude"   "bike_id"                
## [13] "user_type"               "member_birth_year"      
## [15] "member_gender"           "bike_share_for_all_trip"
<p>Information on ride duration, starting and ending time/location, and user descriptions are provided. Notice that the data contains a column called <code>"bike_share_for_all_trip"</code>, which tracks members who are enrolled in the <a href="https://www.fordgobike.com/pricing/bikeshareforall">Bike Share for All</a> program for low-income residents.</p>
<p>We will collapse the data frames into one and examine the data structure:</p>
<pre><code class="r"># Cast certain columns as numeric to enable row binding
df_list[[6]]$start_station_id = as.numeric(df_list[[6]]$start_station_id)
df_list[[6]]$end_station_id = as.numeric(df_list[[6]]$end_station_id)
df_list[[7]]$start_station_id = as.numeric(df_list[[7]]$start_station_id)
df_list[[7]]$end_station_id = as.numeric(df_list[[7]]$end_station_id)
df_list[[8]]$start_station_id = as.numeric(df_list[[8]]$start_station_id)
df_list[[8]]$end_station_id = as.numeric(df_list[[8]]$end_station_id)

# Bind data frames by rows
library(dplyr)   # for data manipulation
df = bind_rows(df_list)

# Glimpse at df
<em>## Observations: 1,210,548
## Variables: 16
## $ duration_sec            <int> 75284, 85422, 71576, 61076, 39966, 647...
## $ start_time              <chr> "2018-01-31 22:52:35.2390", "2018-01-3...
## $ end_time                <chr> "2018-02-01 19:47:19.8240", "2018-02-0...
## $ start_station_id        <dbl> 120, 15, 304, 75, 74, 236, 110, 81, 13...
## $ start_station_name      <chr> "Mission Dolores Park", "San Francisco...
## $ start_station_latitude  <dbl> 37.76142, 37.79539, 37.34876, 37.77379...
## $ start_station_longitude <dbl> -122.4264, -122.3942, -121.8948, -122....
## $ end_station_id          <dbl> 285, 15, 296, 47, 19, 160, 134, 93, 4,...
## $ end_station_name        <chr> "Webster St at O'Farrell St", "San Fra...
## $ end_station_latitude    <dbl> 37.78352, 37.79539, 37.32600, 37.78095...
## $ end_station_longitude   <dbl> -122.4312, -122.3942, -121.8771, -122....
## $ bike_id                 <int> 2765, 2815, 3039, 321, 617, 1306, 3571...
## $ user_type               <chr> "Subscriber", "Customer", "Customer", ...
## $ member_birth_year       <int> 1986, NA, 1996, NA, 1991, NA, 1988, 19...
## $ member_gender           <chr> "Male", "", "Male", "", "Male", "", "M...
## $ bike_share_for_all_trip <chr> "No", "No", "No", "No", "No", "No", "N...
<h3>Format Data Structure</h3>
<p>Data cleaning is essential for downstream analysis. This entails formatting the columns into appropriate data types and extracting essential data. First, I will isolate the hour, day of the week, and month from <code>start_time</code> for visualizing bike usage over time. In addition, I will convert <code>member_birth_year</code> into <code>member_age</code>. Moreover, I will convert <code>duration_sec</code> into <code>duration_min</code>. Finally, columns of type <code>character</code> should take on type <code>factor</code>.</p>
<pre><code class="r"># Format data structure
library(stringr)   # for regular expression

df = df %>%
  # Extract day and month from start_time
  mutate(start_month = sub(" .*", "", start_time)) %>%
  mutate(start_month = as.POSIXct(start_month)) %>%
  mutate(start_day = weekdays(start_month)) %>%
  mutate(start_month = format(start_month, "%B")) %>%

  # Extract hour from start_time
  mutate(start_hour = sub("^.{11}", "", start_time)) %>%
  mutate(start_hour = str_extract(start_hour, "^[0-9]{2}")) %>%

  # Convert birth year to age
  mutate(member_age = 2018 - member_birth_year) %>%

  # Convert duration_sec to duration_min
  mutate(duration_min = duration_sec / 60) %>%

  # Convert characters to factors
  mutate_if(is.character, as.factor) %>%

  # Remove columns not used in the analysis
  select(-c(start_time, end_time, start_station_id, end_station_id, bike_id, member_birth_year, duration_sec))

# Glimpse at cleaned df
<em>## Observations: 1,210,548
## Variables: 14
## $ start_station_name      <fct> Mission Dolores Park, San Francisco Fe...
## $ start_station_latitude  <dbl> 37.76142, 37.79539, 37.34876, 37.77379...
## $ start_station_longitude <dbl> -122.4264, -122.3942, -121.8948, -122....
## $ end_station_name        <fct> Webster St at O'Farrell St, San Franci...
## $ end_station_latitude    <dbl> 37.78352, 37.79539, 37.32600, 37.78095...
## $ end_station_longitude   <dbl> -122.4312, -122.3942, -121.8771, -122....
## $ user_type               <fct> Subscriber, Customer, Customer, Custom...
## $ member_gender           <fct> Male, , Male, , Male, , Male, Male, Ma...
## $ bike_share_for_all_trip <fct> No, No, No, No, No, No, No, No, Yes, Y...
## $ start_month             <fct> January, January, January, January, Ja...
## $ start_day               <fct> Wednesday, Wednesday, Wednesday, Wedne...
## $ start_hour              <fct> 22, 16, 14, 14, 19, 22, 23, 23, 23, 23...
## $ member_age              <dbl> 32, NA, 22, NA, 27, NA, 30, 38, 31, 24...
## $ duration_min            <dbl> 1254.733333, 1423.700000, 1192.933333,...
<p>The cleaned data frame contains 14 descriptors for over a million rides.</p>
<h2>Data Analysis</h2>
<h3>Bike Usage by Month and by Day in 2018</h3>
<p>To start, we will examine the bike usage over time in 2018:</p>
<pre><code class="r"># Organize month
df$start_month = factor(df$start_month, 
                        levels = c("January", "February", "March", "April", "May", "June", "July", "August"))

# Count usage by month
month_counts = table(df$start_month)

# Plot bar graph
bar = barplot(month_counts,
              ylim = c(0, 220000),
              xlab = "Months in 2018",
              ylab = "Number of Rides",
              main = "Bike Usage by Month",
              cex.names = 0.8,
              cex.axis = 0.8)
text(x = bar, y = month_counts,   # Add labels
     label = month_counts, pos = 3, cex = 0.8, col = "red")

# Organize day of the week
df$start_day = factor(df$start_day,
                      levels = c("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"))

# Count usage by day
day_counts = table(df$start_day)

# Plot bar graph
bar = barplot(day_counts,
              ylim = c(0, 230000),
              xlab = "Day of the Week",
              ylab = "Number of Rides",
              main = "Bike Usage by Day of the Week",
              cex.axis = 0.8,
              xaxt = "n")
text(x = bar, y = day_counts,   # Add labels
     label = day_counts, pos = 3, cex = 0.8, col = "red") 
text(x = bar, y = -6000, cex = 0.8,   # Rotate x-axis labels
     labels = names(day_counts), srt = 45, adj = 1, xpd = TRUE)

The first figure shows a consistent growth in the number of rides over the months with the exception of August. Six months into 2018, the bike usage more than doubled. In addition, there's a large increase in the number of rides from April to May, which may be associated with warmer weather in the summer months along with growing adoption of the bike-sharing service. The second figure shows that the usage over weekdays is greater than over weekends.

Bike Usage by Customers and Subscribers

Next, I will stratify the bar chart by user_type to reveal information on GoBike users. A customer is someone who holds a single-ride or day pass while a subscriber carries a monthly or annual pass.

# Plot bar graph
library(ggplot2)   # for plotting functions
ggplot(df, aes(start_month)) + 
  geom_bar() +
  facet_grid(. ~ user_type) +
  xlab("Months in 2018") + 
  ylab("Number of Rides") +
  ggtitle("Comparison of Usage Between Customers and Subscribers") +
  theme_minimal() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))   # Rotate x-axis labels

It is evident that the majority of the rides are made by subscribers while only a fraction (~16%) come from single-use customers. The usage trends over time are very similar between customers and subscribers. The number of users in both categories spiked from April to May and dipped in August.

Bike Usage by Gender

In my experience, the bikes are predominately operated by males. Let's test this intuition and see whether the gender composition of users have evolved over time:

# Organize data by gender
df_gender = df %>%
  filter(member_gender %in% c("Male", "Female")) %>%   # Isolate "Male" and "Female" genders
  mutate(member_gender = droplevels(member_gender))   # Drop unused levels

# Plot barplot
ggplot(df_gender, aes(start_month, fill = member_gender)) +
  geom_bar(position = "fill") +
  xlab("Months in 2018") + 
  ylab("Proportion of Rides") +
  ggtitle("Bike Usage by Gender over Time") +
  scale_fill_discrete("Gender") +

There are roughly three times as many male as female users, and the proportions have remained steady over time.

Distribution of the Age of Users

We have found that male users outnumber the female users. Now, we will examine the distribution of the age of our cohort. It is important to be aware that our metric is “number of rides” not “number of users.” That is to say the height of the bins is a function of both the number of users and frequency of use by individuals in a given age group. For privacy reasons, user IDs are not available to the public.

# Plot histogram
ggplot(df, aes(member_age)) +
  geom_histogram(binwidth = 2, alpha = 0.5) +
  scale_x_continuous(limits = c(10, 100),
                     breaks = seq(10, 100, by = 4)) +
  xlab("Age in Years") + 
  ylab("Number of Rides") +
  ggtitle("Total Rides by Age") +

Take note that 82041 data points are omitted from the plot because age was not provided and that the service is restricted to those 18 years or older. Furthermore, there is reason to question the validity of the self-reported age since the oldest user is recorded as being 137. Nonetheless, the distribution indicates that the ages of the majority of users range from mid-twenties to mid-thirties, with a median of 33 and mean of 35.3.

Distribution of Ride Duration

The bike share system offers a short-term mode of transport, and this is reflected in its pricing structure. 30-minute and 45-minute time limits are imposed on the customers and subscribers, respectively, with a $3 fee for every additional 15 minutes. To avoid surcharges, I hypothesize that most rentals are below the prescribed limit.

# Plot histogram
ggplot(df, aes(duration_min)) +
  geom_histogram(binwidth = 2, alpha = 0.5) +
  scale_x_continuous(limits = c(0, 100),
                     breaks = seq(0, 100, by = 2)) +
  xlab("Ride Duration in Minutes") + 
  ylab("Number of Rides") +
  ggtitle("Duration of Rides") +
  theme_minimal() +
  theme(axis.text.x = element_text(angle = 90))   # Rotate x-axis labels

Indeed, 90% of the rides are below 22.65 minutes and 97% are below 41.3666667, well within the 30- and 45-minute period. The distribution is heavily skewed to the right, reinforcing the idea that the bikes are generally used for short trips.

Bike Usage by Hour

The GoBike is an integral part of my daily commute to work. I suspect that many others use it for the same purpose. Let's explore this by tracking bike usage by the hour:

# Organize usage by hour
hour = df %>%
  group_by(start_hour) %>%
  summarise(total = n())

# Plot line graph
ggplot(hour, aes(x = start_hour, y = total, group = 1)) + 
  geom_line() +
  geom_point(color = "red") +
  xlab("Time in Hours") + 
  ylab("Number of Rides") +
  ggtitle("Total Rides by Hour") +

A bimodal curve with peaks at 8 AM and 5 PM confirms my gut instinct that the bike share system is busiest during commute hours. Along the same lines, it should not come as a surprise that the quietest times are in the wee hours of the night.

Station Usage at Peak Hours

Given the increased usage during commute hours, one may wonder if any ride pattern exists and can be detected at those times. We will focus our investigation on rides in San Francisco. I predict that many people are commuting to the city for work and, therefore, bike rentals will concentrate near major transit stations during the hours of 8 AM and 5 PM.

For those residing in the East Bay, a rail system called Bay Area Rapid Transit (BART) offers an efficient way into the city. Alternatively, a trans-bay bus service picks up from the East Bay and drops off at the Transbay Bus Terminal in San Francisco. Yet another option is the ferry that connects Oakland, Alameda, and Vallejo to the city. For those living in the South Bay (San Jose, for example), a rail system called Caltrain is a popular choice. See the map of the Bay Area below for reference.

library(ggmap)   # for retreiving and plotting Google Maps

# Obtain map of San Francisco Bay Area
BayMap = get_map("San Francisco Bay Area", 
                 maptype = "terrain",
                 source = "google",
                 zoom = 9)

# Plot map and label stations
ggmap(BayMap) +
  geom_text(x = -122.270833, y = 37.804444, label = c("Oakland"), size = 3.1, color = "gray40") +
  scale_x_continuous(limits = c(-122.75, -121.75)) +   # trim x-axis range
  scale_y_continuous(limits = c(37.25, 38.15)) +   # trim y-axis range
  theme_void()   # suppress axes

Before delving into the analysis, here are all the active stations in San Francisco:

# Obtain map of San Francisco
SFmap = get_map("San Francisco",
                maptype = "terrain",
                source = "google",
                zoom = 13)

# Identify all unique stations
df_stations = df %>%
  group_by(start_station_longitude, start_station_latitude) %>%

# Plot map and label stations
ggmap(SFmap) +
  geom_point(data = df_stations, 
             mapping = aes(x = start_station_longitude, 
                           y = start_station_latitude), 
             color = "red") +
  scale_x_continuous(limits = c(-122.45, -122.375)) +   # trim x-axis range
  scale_y_continuous(limits = c(37.74, 37.81)) +   # trim y-axis range
  theme_void()   # suppress axes

Docks are peppered throughout the city with a fairly even distance between stations. At the heart of the coverage area is South of Market, home to Uber and Twitter Headquarters. The downtown area is well-served by GoBikes, and they reach as far south as Bernal Heights.

Now that we have a sense of the bike station locations, it is time to test my hypothesis that stations near transport corridors are more widely used than other stations during working hours. First, I search on Wikipedia for the coordinates of major transit stations in San Francisco. I store this data in df_transit. Next, I write a function to extract the total number of rides taken, in 2018, to and from each stations at a given hour. The function map_trips will generate a figure displaying station usage as output. Check out the code below:

# Coordinates for stations of major transit systems
df_transit = data.frame(name = c("Embarcadeo BART Station",   # station names
                                 "Montgomery BART Station", 
                                 "Powell BART Station", 
                                 "Civic Center BART Station", 
                                 "16th St Mission BART Station", 
                                 "24th St Mission BART Station", 
                                 "King St CalTrain Station",
                                 "22nd St CalTrain Station",
                                 "Ferry Building",
                                 "Transbay Transit Center"),
                        system = c(rep("BART", 6), rep("Caltrain", 2), "Ferry", "Transbay Bus Terminal"),   # transit system
                        longitude = c(-122.3972, -122.4019, -122.4080,   # coordinates
                                      -122.4135, -122.4200, -122.4187, 
                                      -122.3944, -122.3925, -122.3937,
                        latitude = c(37.79306, 37.78936, 37.78400, 
                                     37.77986, 37.76485, 37.75200, 
                                     37.77639, 37.75722, 37.79550,

# Function for visualizing trips at a designated time
map_trips = function(hour, plot_title) {
  # hour = a character of lenth one indicating the hour of interest (e.g. "07" for 7 AM and "15" for 3 PM)
  # plot_title = a character of length one indicating title of plot

  peak_start = df %>%
    group_by(start_hour, start_station_longitude, start_station_latitude) %>%
    rename(station_longitude = start_station_longitude, 
           station_latitude = start_station_latitude) %>%
    summarise(rides = dplyr::n()) %>%   # ride count for each station
    filter(start_hour == hour) %>%
    mutate(type = "Start Stations")

  # Filter for rides taken at 8 AM and grouped by start time and end station
  peak_end = df %>%
    group_by(start_hour, end_station_longitude, end_station_latitude) %>%
    rename(station_longitude = end_station_longitude, 
           station_latitude = end_station_latitude) %>%
    summarise(rides = dplyr::n()) %>%   # ride count for each station
    filter(start_hour == hour) %>%
    mutate(type = "End Stations")

  # Combine starting and ending stations into one data frame
  df_peak = bind_rows(peak_start, peak_end)

  # Convert "type" to factor
  df_peak$type = factor(df_peak$type, 
                        levels = c("Start Stations", "End Stations"))

  # Plot trips
  ggmap(SFmap) +
    geom_point(data = df_peak,   # plot stations
               mapping = aes(x = station_longitude, 
                             y = station_latitude, 
                             size = rides,   # scale point size by number of rides
                             alpha = rides),   # scale point transparency size by number of rides
               color = "red") +
    geom_point(data = df_transit,   # plot transit systems
               mapping = aes(x = longitude,
                             y = latitude, 
                             shape = system),
               size = 2.5) +
    facet_grid(. ~ type) +
    scale_x_continuous(limits = c(-122.45, -122.375)) +   # trim x-axis range
    scale_y_continuous(limits = c(37.74, 37.81)) +   # trim y-axis range
    scale_size_continuous("Number of Rides",
                          range = c(1, 8),
                          breaks = seq(0, 10000, by = 1000)) +   # control point size
    scale_alpha_continuous("Number of Rides",
                           range = c(0.1, 0.8),
                           breaks = seq(0, 10000, by = 1000)) +   # control transparency
    scale_shape_discrete("Transit Systems") +
      ggtitle(plot_title) +
    theme_void()   # suppress axes

Finally, let's compare the bike trips at 8 AM and 5 PM. The size and shading of a red circle corresponds to the number of rides at a station. Bigger and darker equates to more rides. In addition, the bus, train, and ferry stations are marked with symbols.

# Plot trips at 8 AM
map_trips("08", "Bike Trips at 8 AM")

# Plot trips at 5 PM
map_trips("17", "Bike Trips at 5 PM")

At 8 AM, a high volume of rides start near key transit stations as well as the outskirts of the city and finish in downtown San Francisco and the Financial District. Notably, a large number of rides begin at the northernmost Caltrain and BART station and the ferry stop. This is consistent with my prediction that many bike share users commute from outside of the city and rely on bikes to bridge the gap between transit terminals and their workplaces. At 5 PM, we observe a complete reversal of the trend. The bikes move out of the business centers to transit stations and the suburban parts of town.

One important caveat is that the current analysis includes all trips, even those on weekends. Therefore, one may argue that the morning and afternoon ride patterns are influenced by weekend getaways to San Francisco. While a valid point, our previous analyses reduce the weight of this concern. The bar chart for Bike Usage by Day of the Week shows that more rides are made on the weekdays than weekends. In addition, the line plot for Total Rides by Hour shows peak usage at 8 AM and 5 PM. Both suggest that shared bikes are employed as a mean of commuting to work. Even though the weekend data certainly confound our results, it is unlikely that they dictate the emerged trends.

Closing Remarks

Huge respect for those who have made it to the end of this long tutorial. I had an absolute blast probing the data on the bike share system in San Francisco Bay Area.

I encourage those galvanized by this work to explore further on their own. Ford GoBike has a cousin called Citi Bike that operates in New York City, of which the system is more established and mature with data records going back to 2013. Happy data mining!

    Related Post

    1. Analysis and Visualization of Blue Bikes Sharing in Boston
    2. Analysis of Los Angeles Crime with R
    3. Mapping the Prevalence of Alzheimer Disease Mortality in the USA
    4. Animating the Goals of the World Cup: Comparing the old vs. new gganimate and tweenr API
    5. Machine Learning Results in R: one plot to rule them all! (Part 1 – Classification Models)


    1. Visualizing Data


    1. Data Frames
    2. Data Visualisation
    3. ggmap
    4. ggplot2
    5. R Programming
    6. Tips & Tricks

    To leave a comment for the author, please follow the link and comment on their blog: R Programming – DataScience+.

    R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
    Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

    Never miss an update!
    Subscribe to R-bloggers to receive
    e-mails with the latest R posts.
    (You will not see this message again.)

    Click here to close (This popup will not appear again)