logo

This is part I of a two-part series on this case study. You can find part II here.
The final presentation of the findings can be downloaded as a Powerpoint or PDF.

1 Introduction

The scenario for this case study comes from Google’s Data Analytics Professional Certificate. The goal is to help Cyclistic, a fictitious bike-share company based in Chicago, convert casuals to members. The term casual refers to a user without a membership that either pays for each individual ride or purchases a day pass. Members have annual contracts - I go into more detail in Part II. We have been tasked to identify differences between the two using historical trip data, which has been provided by Divvy. I wrote a separate R script to import the data. The aim of this Markdown is to process it to prepare it for analysis.

2 The data

First, we need to load the data and some packages that I’ll be using a lot. I’ll also source some functions for customized plots and tables.

# Load packages
library("tidyverse")
library("kableExtra")

# Load data
load("data/r_data/data_raw.rda")
data <- data_raw

# Source functions
source("r_scripts/functions.R")

2.1 Format

We can use dplyr’s glimpse() to understand the data set’s structure.

glimpse(data)
## Rows: 5,723,532
## Columns: 13
## $ ride_id            <chr> "6C992BD37A98A63F", "1E0145613A209000", "E498E15508…
## $ rideable_type      <fct> classic_bike, docked_bike, docked_bike, classic_bik…
## $ started_at         <dttm> 2021-04-12 18:25:36, 2021-04-27 17:27:11, 2021-04-…
## $ ended_at           <dttm> 2021-04-12 18:56:55, 2021-04-27 18:31:29, 2021-04-…
## $ start_station_name <fct> State St & Pearson St, Dorchester Ave & 49th St, Lo…
## $ start_station_id   <fct> TA1307000061, KA1503000069, 20121, TA1305000034, 20…
## $ end_station_name   <fct> Southport Ave & Waveland Ave, Dorchester Ave & 49th…
## $ end_station_id     <fct> 13235, KA1503000069, 20121, 13235, 20121, 15542, 16…
## $ start_lat          <dbl> 41.89745, 41.80577, 41.74149, 41.90312, 41.74149, 4…
## $ start_lng          <dbl> -87.62872, -87.59246, -87.65841, -87.67394, -87.658…
## $ end_lat            <dbl> 41.94815, 41.80577, 41.74149, 41.94815, 41.74149, 4…
## $ end_lng            <dbl> -87.66394, -87.59246, -87.65841, -87.66394, -87.658…
## $ member_casual      <fct> member, casual, casual, member, casual, casual, cas…

The data set is a record of 5.723.532 bike trips and their time and location, bicycle type and customer type. The data types seem sensible at first glance. The only thing I would like to change is replacing the inefficient character ride_id column with numeric IDs. In order to do so, we first need to make sure that all current ride IDs are unique.

# Number of duplicate ride_ids
sum(duplicated(data$ride_id))
## [1] 0

There are no duplicates, so we can simply replace the IDs with consecutive integers without loosing any information.

# Replace ride_id with integers
data <- data %>%
  mutate(ride_id = 1:n())

Also, ride duration might be interesting and can be easily computed.

# Compute ride duration
data <- data %>%
  mutate(ride_duration = as.numeric(difftime(ended_at,
                                             started_at,
                                             units = "min")))

2.2 Content

Now that everything is in the right format, it’s time to dive deeper into the actual data to see whether there are any missing values or invalid observations. We can compute some summary statistics using base R’s summary().

summary(data)
##     ride_id              rideable_type       started_at
##  Min.   :      1   classic_bike :3250691   Min.   :2021-04-01 00:03:18.00
##  1st Qu.:1430884   docked_bike  : 303989   1st Qu.:2021-06-22 15:20:26.50
##  Median :2861766   electric_bike:2168852   Median :2021-08-17 18:25:49.00
##  Mean   :2861766                           Mean   :2021-08-26 22:25:18.39
##  3rd Qu.:4292649                           3rd Qu.:2021-10-14 19:48:10.50
##  Max.   :5723532                           Max.   :2022-03-31 23:59:47.00
##
##     ended_at                                    start_station_name
##  Min.   :2021-04-01 00:14:29.00   Streeter Dr & Grand Ave:  84011
##  1st Qu.:2021-06-22 15:47:37.00   Michigan Ave & Oak St  :  44260
##  Median :2021-08-17 18:44:32.50   Wells St & Concord Ln  :  44031
##  Mean   :2021-08-26 22:46:50.94   Millennium Park        :  42287
##  3rd Qu.:2021-10-14 20:03:28.50   Clark St & Elm St      :  41215
##  Max.   :2022-04-01 22:10:12.00   (Other)                :4722352
##                                   NA's                   : 745376
##      start_station_id                  end_station_name
##  13022       :  84011   Streeter Dr & Grand Ave:  84694
##  LF-005      :  47763   Michigan Ave & Oak St  :  44635
##  13300       :  45592   Wells St & Concord Ln  :  44124
##  13042       :  44260   Millennium Park        :  42895
##  TA1308000050:  44031   Clark St & Elm St      :  40469
##  (Other)     :4712502   (Other)                :4670468
##  NA's        : 745373   NA's                   : 796247
##       end_station_id      start_lat       start_lng         end_lat
##  13022       :  84694   Min.   :41.64   Min.   :-87.84   Min.   :41.39
##  LF-005      :  53819   1st Qu.:41.88   1st Qu.:-87.66   1st Qu.:41.88
##  13042       :  44635   Median :41.90   Median :-87.64   Median :41.90
##  TA1308000050:  44124   Mean   :41.90   Mean   :-87.65   Mean   :41.90
##  13300       :  43781   3rd Qu.:41.93   3rd Qu.:-87.63   3rd Qu.:41.93
##  (Other)     :4656232   Max.   :45.64   Max.   :-73.80   Max.   :42.17
##  NA's        : 796247                                    NA's   :4716
##     end_lng       member_casual    ride_duration
##  Min.   :-88.97   casual:2546542   Min.   :  -58.03
##  1st Qu.:-87.66   member:3176990   1st Qu.:    6.57
##  Median :-87.64                    Median :   11.72
##  Mean   :-87.65                    Mean   :   21.54
##  3rd Qu.:-87.63                    3rd Qu.:   21.33
##  Max.   :-87.49                    Max.   :55944.15
##  NA's   :4716

Here is what I see:

  • Rideable_type and member_casual seem fine. They are both categorical with three/two categories and all their values fit into one of the categories. The distinction between classic, docked and electric bikes is a little strange, so we might want to investigate that later.
  • All the values in started_at and ended_at have been classified as date-time. Values fall between April 1, 2021 and March 31, 2022 for started_at, which is in line with what we expected. Values for ended_at reach into April 1, 2022, so our data set apparently contains all the trips that started within April 2021 and March 2022, regardless of when they ended.
  • Station names and IDs contain a lot of missing values. These values could be valid though, since bikes might not necessarily need to be parked at stations. However, start_station_id contains fewer missing values than start_station_name. We might be able to infer the missing names based on IDs, but perhaps the missing names indicate that something is wrong with these observations. We’ll have to look into that.
  • Start_lat and start_lng do not contain missing values, but their maximum values are suspicious, since they deviate a lot from the rest of the variables’ summary statistics.
  • End_lat and end_lng summary statistics are more sensible. However, these do contatin quite a few missing values. While missing start and end stations make sense, missing positions do not. Maybe these are indicators of technical errors, such as empty batteries, which would likely make the rest of the variables for these observations invalid as well.
  • Values for ride_duration fall into a huge range of multiple weeks, even though the majority of values seem to fall into a much smaller range. Negative trip durations are impossible and trip durations of multiple days, while possible, do not make a lot of sense either and should be further investigated.

We can now dig deeper based on these preliminary findings. Particularly, we need to examine ride duration and all variables related to start and end locations. Let’s start by looking at ride_duration.

3 Ride duration I

3.1 Distribution

After looking at some basic descriptive statistics, I am interested in the distribution of ride durations.

# Plot ride duration
data %>%
  ggplot() +
  geom_histogram(aes(x = ride_duration/60/24),
                 binwidth = 1, boundary = 0, color = "black") +
  xlab("ride_duration (days)")

Since so many values fall between 0 and 24 hours, it is impossible to distinguish between other bins. Zooming in might help us understand these less common durations.

# Plot ride duration zoomed in
data %>%
  my_ggplot() +
  geom_histogram(aes(x = ride_duration/60/24),
                 binwidth = 1, boundary = 0, color = "black") +
  coord_cartesian(ylim = c(0, 1000)) +
  xlab("ride_duration (days)")

There are more than 625 trips with negative durations. Since start and/or end times of these observations are incorrect, their other measures might be wrong too and hence should not be included in the analysis. Before removing any observations though, I am interested whether ride durations differ between certain bike or membership types, so I’ll create a table of summary statistics by group…

# ride_duration extreme values by group
data %>%
  group_by(member_casual, rideable_type) %>%
  summarize(min = min(ride_duration),
            max = max(ride_duration),
            mean = mean(ride_duration),
            sd = sd(ride_duration),
            n = n()) %>%
  ungroup() %>%
  mutate(percentage = n*100 / sum(n)) %>%
  kable_custom()
member_casual rideable_type min max mean sd n percentage
casual classic_bike -53.5 1559.9 28.9 77.5 1257643 22.0
casual docked_bike -40.8 55944.2 82.8 742.4 303989 5.3
casual electric_bike -58.0 487.3 19.6 23.6 984910 17.2
member classic_bike -54.1 1559.9 14.0 32.4 1993048 34.8
member electric_bike -53.8 480.5 12.4 15.8 1183942 20.7

… and another histogram by group.

# Plot ride_duration zoomed in by group
data %>%
  my_ggplot() +
  geom_histogram(aes(x = ride_duration/60/24),
                 binwidth = 1, boundary = 0, color = "black") +
  coord_cartesian(ylim = c(0, 1000)) +
  scale_y_continuous(breaks = seq(0,1000,250)) +
  xlab("ride_duration (days)") +
  facet_grid(rows = vars(member_casual), cols = vars(rideable_type))

Key observations are:

  • Negative trip durations occur in all groups and are only slightly more common (relatively) in electric bikes.
  • Docked bikes seem to be responsible for all trips with a duration of more than 26 hours.
  • Docked bikes stand out due to their high number of long trips and their relatively low number of total trips.
  • Docked bikes have not been used by members.

Unfortunately, I cannot find anything on docked bikes in either the case study scenario provided by Google (where there is no mention of electric bikes either), or Divvy’s website (https://divvybikes.com/), who provide the real-world data set for this case study. Given that docked bikes stand out in terms of trip durations and the fact that they have only been used by casuals and I cannot find any explanations for either, I decided to remove these data points and continue my analysis without docked bikes. If this were a real-life case, I would obviously get help from others, however, I think looking at electric bikes vs classic bikes is sufficient for this case study.

3.2 Removing outliers

I am going to track any changes I make along with their type (modification vs removal of observations) and their specific reasons in a changelog. In order to do so efficiently, I’ll create a function that I’ll be able to use whenever I modify or remove observations.

# Custom function for tracking changes
track_changes <- function (data, data_old, changelog, type, reason) {
  # Extract affected rows
  cl_temp <- anti_join(data_old, data) %>%
    select(ride_id, member_casual, rideable_type) %>%
    add_column(type = type, reason = reason) %>% # add type and reason
    distinct() # select only distinct observations for long format

  # Bind to existing entries
  bind_rows(changelog, cl_temp)
}

# Set up changelog
changelog <- tibble()

Now that we’re set up, we can delete the unneeded rows…

# Copy of unchanged dataframe
data_old <- data

# Delete observations where rideable_type == docked_bike
data <- data %>% filter(rideable_type != "docked_bike")

# Remove unneeded factor level
data$rideable_type <- fct_drop(data$rideable_type)

… and record the changes we made.

# Changelog entry
changelog <- track_changes(data, data_old, changelog, "removal", "docked_bikes")

I also found on Divvy’s website that their data sets are not supposed to include any trips with a duration of less than 1 minute, since these are commonly caused by false starts or users trying to redock their bikes. Accordingly, I decided to remove these trips. This also takes care of trips with negative durations. Even though this might remove a small number of valid observations, the majority of the data points affected were most likely created through technical errors or other odd events and are thus invalid.

# Copy of unchanged dataframe
data_old <- data

# Delete observations where ride_duration < 1 min
data <- data %>% filter(ride_duration >= 1)

# Changelog entry
changelog <- track_changes(data, data_old, changelog, "removal", "duration_<_1min")

3.3 New distribution

Let’s plot ride duration again for the remaining observations.

# Plot ride duration again
data %>%
  my_ggplot() +
  geom_histogram(aes(x = ride_duration/60),
                 binwidth = 1, boundary = 0, color = "black") +
  xlab("ride_duration (hours)")

And zoomed in.

# Plot ride duration again zoomed in
data %>%
  my_ggplot() +
  geom_histogram(aes(x = ride_duration/60),
                 binwidth = 1, boundary = 0, color = "black") +
  coord_cartesian(ylim = c(0, 150000)) +
  xlab("ride_duration (hours)")

This looks much better already. The range has gotten a lot smaller. However, there are still some very long trips and the spike in trips that took between 24 and 25 hours is particularly odd.

We could simply choose an arbitrary cutoff e.g. based on Tukey (upper quartile + 1.5*IQR), even though that one in particular seems a bit conservative in this case as it would throw out all trips with a duration of more than half an hour. Using the 99th percentile for example would leave us with only trips lasting less than 1 3/4 hours. An arbitrary cut off at 3 hours would leave us with 99.68% of observations.

Even though any arbitrary cutoff will almost certainly remove not only invalid observations, but valid ones as well, this could easily be justified by the fact that these extreme customer behaviors should not affect our marketing recommendations anyway. Instead, we should focus on properly targetting the majority of casuals, particularly the ones likely to convert (which are probably not the ones showcasing these extreme behaviors), rather than attempting to appeal to every single individual. However, I am curious whether there is a better way to filter out invalid observations using other variables that might be related to long trip durations. Accordingly, I will leave the data set as is for now.

4 Start and end location

Other than trip duration, we also need to validate start and end stations and coordinates. Since there are certain relationships between station ID (station_id), name (station_name) and geographical coordinates (lat and lng), it makes sense to investigate them together rather than individually.

4.1 Multiple missing values

First, lets look at missing values, beginning with observations in which both start_station_id and start_station_name are missing.

# Missing start_station_name and start_station_id by rideable_type
data %>%
  select(start_station_name, start_station_id, rideable_type) %>%
  mutate(across(c(start_station_name, start_station_id), is.na)) %>%
  mutate(both = start_station_name & start_station_id) %>%
  group_by(rideable_type) %>%
  summarize(missing_values = sum(both)) %>%
  kable_custom()
rideable_type missing_values
classic_bike 0
electric_bike 722973

It looks like classic bikes need to be parked at stations, while electric bikes do not. Fortunately, as we saw earlier, there are no missing values for start_lat and start_lng, so we probably don’t need to be worried about the missing values for start_station_id and start_station_name for electric bikes. Let’s examine end stations now.

# Missing end_station_name and end_station_id by rideable_type
data %>%
  select(end_station_name, end_station_id, rideable_type, member_casual) %>%
  mutate(across(c(end_station_name, end_station_id), is.na)) %>%
  mutate(both = end_station_name & end_station_id) %>%
  group_by(rideable_type, member_casual) %>%
  summarize(missing_values = sum(both)) %>%
  kable_custom()
rideable_type member_casual missing_values
classic_bike casual 4893
classic_bike member 3591
electric_bike casual 362751
electric_bike member 397999

Even though classic bikes are supposed to be parked at stations, that’s obviously not always the case. Earlier, we also noticed that there were some missing geographic coordinates for end locations. Let’s summarize these data points to see whether there is a connection.

# Summary where is.na(end_lat & end_lng)
data %>%
  filter(is.na(end_lat) & is.na(end_lng)) %>%
  summary()
##     ride_id              rideable_type    started_at
##  Min.   :   5630   classic_bike :4221   Min.   :2021-04-01 11:15:46.00
##  1st Qu.:1230585   electric_bike:   0   1st Qu.:2021-06-13 21:57:48.00
##  Median :2181890                        Median :2021-07-30 20:58:15.00
##  Mean   :2560832                        Mean   :2021-08-08 21:45:40.11
##  3rd Qu.:3891815                        3rd Qu.:2021-09-17 13:01:17.00
##  Max.   :5707838                        Max.   :2022-03-31 13:52:51.00
##
##     ended_at                                    start_station_name
##  Min.   :2021-04-01 18:36:18.00   Streeter Dr & Grand Ave:  80
##  1st Qu.:2021-06-14 20:50:47.00   Millennium Park        :  73
##  Median :2021-07-31 17:33:55.00   Wabash Ave & Grand Ave :  37
##  Mean   :2021-08-09 15:04:25.91   Michigan Ave & 8th St  :  36
##  3rd Qu.:2021-09-17 18:22:37.00   Michigan Ave & Oak St  :  33
##  Max.   :2022-04-01 14:52:43.00   Dearborn St & Erie St  :  29
##                                   (Other)                :3933
##      start_station_id                   end_station_name end_station_id
##  13022       :  80    2112 W Peterson Ave       :   0    13001  :   0
##  13008       :  73    63rd St Beach             :   0    13006  :   0
##  13300       :  47    900 W Harrison St         :   0    13008  :   0
##  TA1307000117:  37    Aberdeen St & Jackson Blvd:   0    13011  :   0
##  623         :  36    Aberdeen St & Monroe St   :   0    13016  :   0
##  13042       :  33    (Other)                   :   0    (Other):   0
##  (Other)     :3915    NA's                      :4221    NA's   :4221
##    start_lat       start_lng         end_lat        end_lng     member_casual
##  Min.   :41.65   Min.   :-87.77   Min.   : NA    Min.   : NA    casual:3169
##  1st Qu.:41.87   1st Qu.:-87.66   1st Qu.: NA    1st Qu.: NA    member:1052
##  Median :41.89   Median :-87.64   Median : NA    Median : NA
##  Mean   :41.88   Mean   :-87.64   Mean   :NaN    Mean   :NaN
##  3rd Qu.:41.92   3rd Qu.:-87.62   3rd Qu.: NA    3rd Qu.: NA
##  Max.   :42.06   Max.   :-87.53   Max.   : NA    Max.   : NA
##                                   NA's   :4221   NA's   :4221
##  ride_duration
##  Min.   :   1.0
##  1st Qu.: 225.2
##  Median :1499.8
##  Mean   :1038.8
##  3rd Qu.:1499.9
##  Max.   :1559.9
## 

None of the observations with missing end_lat and end_lng have data for station name or ID that could be used to infer the missing values. Also notice that these are all classic bikes, more than three quarters of these trips were made by casuals and the ride durations are extremely high. Whether they represent cases of misuse or technical errors, these observations are very likely invalid and at the very least irrelevant for what we are looking to achieve. They can thus be removed.

# Copy of unchanged dataframe
data_old <- data

# Remove where end_lat and end_lng are NA
data <- data %>% filter(!is.na(end_lat) | !is.na(end_lng))

# Changelog entry
changelog <- track_changes(data, data_old, changelog,
                           "removal", "no_geographical_coordinates")

Deleting these observations did not get rid of all missing values for end_station_id and end_station_name for classic bikes. However, the remaining observations at least have geographical coordinates that could be used to infer station names and IDs. Let’s look at the summary statistics of those observations.

data %>%
  filter((is.na(end_station_id) & rideable_type == "classic_bike")) %>%
  summary()
##     ride_id              rideable_type    started_at
##  Min.   :   5746   classic_bike :4263   Min.   :2021-04-01 10:41:01.00
##  1st Qu.:2126199   electric_bike:   0   1st Qu.:2021-07-04 18:21:15.00
##  Median :3372609                        Median :2021-09-04 22:01:52.00
##  Mean   :3186632                        Mean   :2021-09-05 06:17:35.26
##  3rd Qu.:4601606                        3rd Qu.:2021-10-26 05:32:42.50
##  Max.   :5703180                        Max.   :2022-03-31 18:03:24.00
##
##     ended_at                                         start_station_name
##  Min.   :2021-04-01 10:47:45.00   Streeter Dr & Grand Ave     :  71
##  1st Qu.:2021-07-04 20:16:18.00   Clark St & Elm St           :  45
##  Median :2021-09-05 08:13:54.00   Wabash Ave & Grand Ave      :  41
##  Mean   :2021-09-05 09:40:08.64   Fairbanks Ct & Grand Ave    :  37
##  3rd Qu.:2021-10-26 07:42:41.00   Clinton St & Washington Blvd:  35
##  Max.   :2022-03-31 19:52:43.00   Michigan Ave & Washington St:  34
##                                   (Other)                     :4000
##      start_station_id                   end_station_name end_station_id
##  13022       :  71    2112 W Peterson Ave       :   0    13001  :   0
##  13300       :  48    63rd St Beach             :   0    13006  :   0
##  TA1307000039:  45    900 W Harrison St         :   0    13008  :   0
##  TA1307000117:  41    Aberdeen St & Jackson Blvd:   0    13011  :   0
##  TA1305000003:  37    Aberdeen St & Monroe St   :   0    13016  :   0
##  WL-012      :  35    (Other)                   :   0    (Other):   0
##  (Other)     :3986    NA's                      :4263    NA's   :4263
##    start_lat       start_lng         end_lat         end_lng
##  Min.   :41.65   Min.   :-87.77   Min.   :41.65   Min.   :-87.77
##  1st Qu.:41.88   1st Qu.:-87.66   1st Qu.:41.88   1st Qu.:-87.66
##  Median :41.90   Median :-87.64   Median :41.89   Median :-87.64
##  Mean   :41.90   Mean   :-87.64   Mean   :41.90   Mean   :-87.64
##  3rd Qu.:41.93   3rd Qu.:-87.63   3rd Qu.:41.93   3rd Qu.:-87.63
##  Max.   :42.06   Max.   :-87.54   Max.   :42.06   Max.   :-87.54
##
##  member_casual ride_duration
##  casual:1724   Min.   :   1.017
##  member:2539   1st Qu.:  16.983
##                Median :  49.083
##                Mean   : 202.556
##                3rd Qu.: 196.292
##                Max.   :1559.683
## 

Apparently, these values are not missing at random, as their ride durations are highly different from the rest of the observations. I cannot see any reasons why this would be the case if these data points were valid, so I am going to delete them.

# Copy of unchanged dataframe
data_old <- data

# Delete classic bike trip with no end station
data <- data %>%
  filter(!(is.na(end_station_id) & rideable_type == "classic_bike"))

# Changelog entry
changelog <- track_changes(data, data_old, changelog,
                           "removal", "classic_and_no_end_station")

4.2 Missing station name or ID

Let’s now look at observations where either station name or ID, and/or either latitude or longitude are missing. In order to do so efficiently for start and end locations at the same time, I am going to first reshape the data frame to a longer format, where start and end location of a given trip each make up distinct observations.

# Long format for stations
data <- data %>%
  pivot_longer(cols = matches("^start_|^end_"),
               names_to = c("type", ".value"),
               names_pattern = '(^[^_]+(?=_))_(.*)') %>%
  mutate(type = as.factor(type))

# Glimpse
glimpse(data)
## Rows: 10,647,152
## Columns: 11
## $ ride_id       <int> 1, 1, 4, 4, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13…
## $ rideable_type <fct> classic_bike, classic_bike, classic_bike, classic_bike, …
## $ started_at    <dttm> 2021-04-12 18:25:36, 2021-04-12 18:25:36, 2021-04-17 09…
## $ ended_at      <dttm> 2021-04-12 18:56:55, 2021-04-12 18:56:55, 2021-04-17 09…
## $ member_casual <fct> member, member, member, member, casual, casual, casual, …
## $ ride_duration <dbl> 31.316667, 31.316667, 25.100000, 25.100000, 1.433333, 1.…
## $ type          <fct> start, end, start, end, start, end, start, end, start, e…
## $ station_name  <fct> State St & Pearson St, Southport Ave & Waveland Ave, Hon…
## $ station_id    <fct> TA1307000061, 13235, TA1305000034, 13235, 16948, 16948, …
## $ lat           <dbl> 41.89745, 41.94815, 41.90312, 41.94815, 41.77937, 41.779…
## $ lng           <dbl> -87.62872, -87.66394, -87.67394, -87.66394, -87.66484, -…

This format will make the upcoming operations much easier. Now we can look for observations with either missing station name or ID for start and end stations at the same time.

# Find observations where one is missing
data %>%
  filter((is.na(station_name) & !is.na(station_id)) |
                       (is.na(station_id) & !is.na(station_name)) |
                       (is.na(lat) & !is.na(lng)) |
                       (is.na(lng) & !is.na(lat))) %>%
  kable_custom()
ride_id rideable_type started_at ended_at member_casual ride_duration type station_name station_id lat lng
2167722 electric_bike 2021-07-18 03:44:22 2021-07-18 04:12:23 casual 28.0 start NA 13221 41.9 -87.7
3293222 electric_bike 2021-09-21 18:18:59 2021-09-21 18:21:48 casual 2.8 start NA 20215 41.6 -87.5
3900741 electric_bike 2021-09-22 07:14:42 2021-09-22 07:22:38 casual 7.9 start NA WL-008 41.9 -87.6

All three observations are missing start station names. Let’s see whether we can use the station IDs to find their names.

# Find all station names associated with the three station IDs
data %>%
  select(station_id, station_name) %>%
  filter(station_id %in% c("13221", "20215", "WL-008")) %>%
  group_by(station_id, station_name) %>%
  summarize(n = n()) %>%
  kable_custom()
station_id station_name n
13221 Wood St & Milwaukee Ave 32645
13221 NA 1
20215 Hegewisch Metra Station 142
20215 NA 1
WL-008 Clinton St & Roosevelt Rd 16114
WL-008 NA 1

Each of the three IDs is associated with one specific station name, which is only missing for one observation. Since there is nothing curious about the observations, other than the missing station name, I will simply assign the station names based on their IDs. Now that the data is in this longer format, we need to be careful with our changelog, since two removed rows do not necessarily equate to two removed observations, since both rows could belong to the same trip. I am going to use “n” to refer to the number of deleted rows and “trips” to refer to the number of deleted observations.

# Copy of unchanged dataframe
data_old <- data

# Assign station names based on station IDs
data <- data %>%
  mutate(station_name = as.factor(case_when(
    station_id == "13221" ~ "Wood St & Milwaukee Ave",
    station_id == "20215" ~ "Hegewisch Metra Station",
    station_id == "WL-008" ~ "Clinton St & Roosevelt Rd",
    TRUE ~ as.character(station_name))))

# Changelog entry
changelog <- track_changes(data, data_old, changelog,
                           "modification", "fix_NA_station_names")

4.3 Station names associated with multiple IDs

Next, I would like to know whether there are any station names that are associated with more than one station ID.

# Find station_names that are associated with multiple station_ids
data %>%
  mutate(start = ifelse(type == "start", TRUE, FALSE),
         end = ifelse(type == "end", TRUE, FALSE)) %>%
  group_by(station_name, station_id) %>%
  summarize(n = n(),
            start = as.logical(sum(start)),
            end = as.logical(sum(end))) %>%
  group_by(station_name) %>%
  filter(n() > 1) %>%
  kable_custom()
station_name station_id n start end
Lakefront Trail & Bryn Mawr Ave KA1504000152 28894 TRUE TRUE
Lakefront Trail & Bryn Mawr Ave 15576 1055 TRUE TRUE
Loomis St & 89th St 201022 253 TRUE TRUE
Loomis St & 89th St 20102 88 TRUE TRUE

There are two station names in our data set that each are associated with two different IDs. We should try to find out whether, despite having two IDs, these station names still always refer to the same station. In order to do so, we can look at their geographical coordinates and the time frames in which each station has been used. We will only consider classic bike trips, since geographical coordinates are more variable for electric bikes and can vary even for individual stations.

# Look at stations that are associated with multiple IDs
data %>%
  filter(station_name %in% c("Lakefront Trail & Bryn Mawr Ave",
                             "Loomis St & 89th St") &
           rideable_type == "classic_bike") %>%
  group_by(station_name, station_id, lat, lng) %>%
  summarize(min = min(started_at),
            max = max(started_at)) %>%
  kable_custom()
station_name station_id lat lng min max
Lakefront Trail & Bryn Mawr Ave KA1504000152 42.0 -87.7 2021-04-01 09:37:21 2022-02-25 23:49:20
Lakefront Trail & Bryn Mawr Ave 15576 42.0 -87.7 2022-02-28 14:56:25 2022-03-31 22:05:02
Loomis St & 89th St 201022 41.7 -87.7 2021-05-20 18:37:39 2021-10-11 10:33:28
Loomis St & 89th St 20102 41.7 -87.7 2021-12-06 07:13:46 2022-03-26 15:29:01

It seems that both stations were closed for a bit and then reopened under the same name but using a different ID. However, Loomis St & 89th St stayed in exactly the same location, while Lakefront Trail & Bryn Mawr Ave seemed to only have moved slightly. We can calculate the distance in meters via the Haversine Distance.

# Distance between two station versions
geosphere::distHaversine(c(41.98404, -87.65231), c(41.98404, -87.65228))
## [1] 3.339585

Since Loomis St & 89th St has not moved at all and Lakefront Trail & Bryn Mawr Ave moved less than 3.5m, I think it makes sense for our purposes to treat both “versions” of each station as the same station. Hence, I will replace the old IDs with the new ones. But first, we need to make sure that neither of the IDs is associated with another station name.

# Find all station names associated with IDs
data %>%
  filter(station_id %in% c("KA1504000152", "15576", "201022", "20102")) %>%
  group_by(station_id, station_name) %>%
  summarize(n = n()) %>%
  kable_custom()
station_id station_name n
KA1504000152 Lakefront Trail & Bryn Mawr Ave 28894
201022 Loomis St & 89th St 253
20102 Loomis St & 89th St 88
15576 Lakefront Trail & Bryn Mawr Ave 1055

Seems like we’re good to go, so we can simply replace the old IDs with the new ones.

# Copy of unchanged dataframe
data_old <- data

# Same IDs for same stations
data <- data %>%
  mutate(station_id = as.factor(case_when(
    station_id == "KA1504000152" ~ "15576",
    station_id == "201022" ~ "20102",
    TRUE ~ as.character(station_id))))

# Changelog entry
changelog <- track_changes(data, data_old, changelog,
                           "modification", "multiple_IDs_for_same_station")

4.4 Station IDs associated with multiple names

Now that we’ve looked at station names that are associated with multiple IDs, we can do the same the other way around.

# Find station_IDs that are associated with multiple station_names
data %>%
  mutate(start = ifelse(type == "start", TRUE, FALSE),
         end = ifelse(type == "end", TRUE, FALSE)) %>%
  group_by(station_name, station_id) %>%
  summarize(n = n(),
            min = min(ride_duration),
            mean = mean(ride_duration),
            median = median(ride_duration),
            max = max(ride_duration),
            casuals_perc = 100*sum(member_casual == "casual")/n(),
            start = as.logical(sum(start)),
            end = as.logical(sum(end))) %>%
  group_by(station_id) %>%
  filter(n() > 1) %>%
  arrange(station_id) %>%
  relocate(2) %>%
  kable_custom() %>%
  scroll_box(width = "100%", height = "300px")
station_id station_name n min mean median max casuals_perc start end
13099 Halsted St & 18th St 3335 1.0 18.4 12.8 1141.8 39.1 TRUE TRUE
13099 Halsted St & 18th St (Temp) 3467 1.0 16.3 12.0 422.1 40.6 TRUE TRUE
13300 DuSable Lake Shore Dr & Monroe St 36255 1.0 32.5 25.4 1271.5 68.7 TRUE TRUE
13300 Lake Shore Dr & Monroe St 31655 1.0 34.0 26.6 1388.7 69.9 TRUE TRUE
351 351 2 17.1 27.7 27.7 38.3 100.0 TRUE FALSE
351 Mulligan Ave & Wellington Ave 533 1.2 15.9 9.7 214.1 40.5 TRUE TRUE
444 N Shore Channel Trail & Argyle Ave 21 3.5 18.7 9.7 72.0 47.6 TRUE TRUE
444 N Shore Channel Trail & Argyle St 56 3.3 19.8 13.6 89.8 42.9 TRUE TRUE
LF-005 DuSable Lake Shore Dr & North Blvd 48478 1.0 23.4 17.7 1431.3 57.5 TRUE TRUE
LF-005 Lake Shore Dr & North Blvd 40753 1.0 25.0 19.2 1406.9 60.1 TRUE TRUE
TA1305000039 Elston Ave & Cortland St 14101 1.0 13.8 10.9 692.6 40.2 TRUE TRUE
TA1305000039 Marshfield Ave & Cortland St 2786 1.0 18.1 13.5 1335.4 34.9 TRUE TRUE
TA1306000029 DuSable Lake Shore Dr & Ohio St 18667 1.0 24.5 16.9 1222.1 54.7 TRUE TRUE
TA1306000029 Lake Shore Dr & Ohio St 15983 1.0 27.3 19.3 1012.2 53.9 TRUE TRUE
TA1306000029 McClurg Ct & Ohio St 2902 1.0 21.8 10.2 1435.5 24.1 TRUE TRUE
TA1307000041 DuSable Lake Shore Dr & Wellington Ave 24115 1.0 21.0 16.3 1058.8 42.8 TRUE TRUE
TA1307000041 Lake Shore Dr & Wellington Ave 17936 1.0 23.2 17.8 1082.9 45.3 TRUE TRUE
TA1309000039 DuSable Lake Shore Dr & Diversey Pkwy 22807 1.0 20.7 15.5 1200.9 45.3 TRUE TRUE
TA1309000039 Lake Shore Dr & Diversey Pkwy 18290 1.0 22.9 16.8 1368.9 48.9 TRUE TRUE
TA1309000049 DuSable Lake Shore Dr & Belmont Ave 24713 1.0 22.1 17.1 1390.9 45.9 TRUE TRUE
TA1309000049 Lake Shore Dr & Belmont Ave 17224 1.0 24.7 19.3 940.9 50.6 TRUE TRUE

Since, by design, IDs can be associated with multiple names, it is not surprising that we found a few of those cases. I won’t change those station names to be consistent, since it’s perfectly possible that the names actually changed at some point. However, it is important to take this into account later when analyzing members and casuals regarding their station usage, when we might be better off using IDs rather than names. Other than that, the one thing that seems a bit strange is station “351” whose name is the same as its ID. However, other than what is apparently a naming error that only occurred twice, the station seems perfectly normal. I think we can simply change the station name for those two observations and keep them in our data set.

# Copy of unchanged dataframe
data_old <- data

# Change station name according to ID
data <- data %>%
  mutate(station_name = ifelse(station_name == "351",
                        "Mulligan Ave & Wellington Ave",
                        as.character(station_name)) %>%
           as.factor())

# Changelog entry
changelog <- track_changes(data, data_old, changelog,
                           "modification", "ID_instead_of_name")

4.5 Curious station names and IDs

Let’s see whether there are any more observations where station ID and name are identical.

# Identical station ID and name
data %>%
  filter(as.character(station_id) == as.character(station_name)) %>%
  group_by(station_id, station_name) %>%
  summarize(n = n(),
            min = min(ride_duration),
            mean = mean(ride_duration),
            median = median(ride_duration),
            max = max(ride_duration),
            casuals_perc = 100*sum(member_casual == "casual")/n()) %>%
  kable_custom()
station_id station_name n min mean median max casuals_perc
Bissell St & Armitage Ave - Charging Bissell St & Armitage Ave - Charging 34 1.4 7.3 7.1 19.0 35.3
DIVVY CASSETTE REPAIR MOBILE STATION DIVVY CASSETTE REPAIR MOBILE STATION 8 13.4 66.4 39.6 123.4 100.0
Hastings WH 2 Hastings WH 2 3 1.1 1.2 1.1 1.3 0.0
Pawel Bialowas - Test- PBSC charging station Pawel Bialowas - Test- PBSC charging station 2 2.6 2.6 2.6 2.6 100.0
Throop/Hastings Mobile Station Throop/Hastings Mobile Station 1 19.9 19.9 19.9 19.9 0.0
Wilton Ave & Diversey Pkwy - Charging Wilton Ave & Diversey Pkwy - Charging 38 1.1 8.7 6.9 28.2 26.3

Based on their names, their very low traffic and the mostly unusual ride durations, these stations seem to be service or repair stations. They are probably used mainly for service trips and thus irrelevant to our analysis and can be removed.

# Copy of unchanged dataframe
data_old <- data

# Delete observations where station ID and name are identical
data <- data %>%
  filter(as.character(station_id) != as.character(station_name) |
           is.na(station_id) | is.na(station_name))

# Changelog entry
changelog <- track_changes(data, data_old, changelog,
                           "removal", "identical_ID_and_name")

Now we should check if there are any other stations that contain the key words “test”, “repair” or “charging”.

# Search for other stations
data %>%
  filter(if_any(matches("name$|id$"),
                ~ str_detect(.,regex("test|repair|charging",
                                     ignore_case = TRUE)))) %>%
  group_by(station_id, station_name) %>%
  summarize(n = n(),
            min = min(ride_duration),
            mean = mean(ride_duration),
            median = median(ride_duration),
            max = max(ride_duration),
            casuals_perc = 100*sum(member_casual == "casual")/n()) %>%
  kable_custom()
station_id station_name n min mean median max casuals_perc
Hubbard Bike-checking (LBS-WH-TEST) Base - 2132 W Hubbard Warehouse 854 1.1 51.2 13.9 1479.0 20.3

This station too seems to be a service station or warehouse which is used for internal operations. The observations are thus likely irrelevant and can be removed.

# Copy of unchanged dataframe
data_old <- data

# Delete Warehouse observations
data <- data %>%
  filter(station_name != "Base - 2132 W Hubbard Warehouse" |
           is.na(station_name))

# Changelog entry
changelog <- track_changes(data, data_old, changelog, "removal", "warehouse")

Let’s see if there are other station names or IDs containing the word “warehouse”.

# Stations names or IDs that contain the word "warehouse"
data %>%
  filter(if_any(matches("name$|id$"),
                ~ str_detect(.,regex("warehouse",
                                     ignore_case = TRUE)))) %>%
  group_by(station_id, station_name) %>%
  summarize(n = n(),
            min = min(ride_duration),
            mean = mean(ride_duration),
            median = median(ride_duration),
            max = max(ride_duration),
            casuals_perc = 100*sum(member_casual == "casual")/n()) %>%
  kable_custom()
## Warning in min(ride_duration): no non-missing arguments to min; returning Inf
## Warning in max(ride_duration): no non-missing arguments to max; returning -Inf
station_id station_name n min mean median max casuals_perc

The search did not identify any other stations. This is all I can think of in terms of cleaning station IDs and names. Lastly, let’s check for the extreme values in start_lat and start_lng that we saw earlier.

# Find min and max coordinates
data %>%
  summarize(min_lat = min(lat),
            max_lat = max(lat),
            min_lng = min(lng),
            max_lng = max(lng)) %>%
  kable_custom()
min_lat max_lat min_lng max_lng
41.4 42.2 -89.0 -87.5

Seems like we already deleted those extreme observations, as these values here all fall within the expected range.

5 Ride duration II

5.1 Recheck distribution

All that’s left to do now is examine ride duration in combination with start and end locations in order to possibly identify more invalid data points. First, we need to get the data frame back into its original format.

# Keep only full observations (start and end)
duplicate_ids <- table(data$ride_id) %>% .[.>1] %>% names() %>% as.numeric()
data <- data %>% filter(ride_id %in% duplicate_ids)

# Transform long to wide
data <- data %>%
  pivot_wider(names_from = "type",
              values_from = c("station_name", "station_id", "lat", "lng"),
              names_glue = "{type}_{.value}") %>%
  relocate(colnames(data_raw))

# Drop unneeded levels
data <- droplevels(data)

Plot trip duration again to see whether anything has changed.

# Plot ride duration again
data %>%
  my_ggplot() +
  geom_histogram(aes(x = ride_duration/60),
                 binwidth = 1, boundary = 0, color = "black") +
  xlab("ride_duration (hours)")

Zoom in and plot by bike and membership type.

# Plot ride duration again zoomed in by group
data %>%
  my_ggplot() +
  geom_histogram(aes(x = ride_duration/60),
                 binwidth = 1, boundary = 0, color = "black") +
  coord_cartesian(ylim = c(0, 1500)) +
  xlab("ride_duration (hours)") +
  facet_grid(rows = vars(member_casual), cols = vars(rideable_type))

Compared to the last time we examined ride_duration, we got rid of all of the values that were larger than 25 hours, as well as the spike in the number of trips that took between 24 and 25 hours.

5.2 Ride duration by station

Now we can bring ride_duration, station_id and station_name together to hopefully identify some patterns in the remaining outliers in terms of start and/or end station that allow us to identify invalid observations. In order to do so, we can look at the stations that are associated with the highest mean ride durations. If any stations are only associated with unrealistically long rides, we could remove all observations from those stations.

# Top six start station IDs with highest mean ride duration
data %>%
  group_by(start_station_id) %>%
  summarize(mean_duration = mean(ride_duration),
            n = n()) %>%
  arrange(-mean_duration) %>%
  head() %>%
  kable_custom()
start_station_id mean_duration n
564 85.2 52
422 75.5 7
20212 71.5 97
421 67.7 5
20225 64.9 80
587 55.4 55
# Top six start station names with highest mean ride duration
data %>%
  group_by(start_station_name) %>%
  summarize(mean_duration = mean(ride_duration),
            n = n()) %>%
  arrange(-mean_duration) %>%
  head() %>%
  kable_custom()
start_station_name mean_duration n
Racine Ave & 65th St 85.2 52
Albany Ave & 16th St 75.5 7
Altgeld Gardens 71.5 97
Albany Ave & Douglas Blvd 67.7 5
Commercial Ave & 100th St 64.9 80
Wabash Ave & 83rd St 55.4 55
# Top six end station IDs with highest mean ride duration
data %>%
  group_by(end_station_id) %>%
  summarize(mean_duration = mean(ride_duration),
            n = n()) %>%
  arrange(-mean_duration) %>%
  head() %>%
  kable_custom()
end_station_id mean_duration n
564 87.3 43
421 86.2 7
394 57.8 18
20212 57.4 67
416 54.0 6
586 52.4 76
# Top six end station names with highest mean ride duration
data %>%
  group_by(end_station_name) %>%
  summarize(mean_duration = mean(ride_duration),
            n = n()) %>%
  arrange(-mean_duration) %>%
  head() %>%
  kable_custom()
end_station_name mean_duration n
Racine Ave & 65th St 87.3 43
Albany Ave & Douglas Blvd 86.2 7
Panama Ave & Grace St 57.8 18
Altgeld Gardens 57.4 67
Washtenaw Ave & Madison St 54.0 6
MLK Jr Dr & 83rd St 52.4 76

Looks like no stations are exclusively used for extreme outliers in terms of ride duration. Unfortunately, this means that there are no patterns in the remaining observations that can help us identify invalid data points or find a ride duration cut-off.

5.3 Choosing a cut-off value

Given the size of the current data set, a few invalid data points would probably not affect our analysis too much. Yet, for reasons mentioned earlier (properly focusing on the majority of customers and potential members rather than trying to please every single one and letting extreme customer behaviors affect our marketing, possibly resulting in not properly targeting any customers), I would prefer to exclude some more outliers. Since there is no clear cut-off or other criteria by which we can define outliers or identify invalid data points, we will simply have to make a somewhat arbitrary but informed decision.

According to their website, Divvy memberships include unlimited 45 minute rides, after which members need to either switch bikes or pay $0.16 per minute. Non-members have to pay $1 to unlock the bike and then start paying $0.16 per minute straight from the start. A day pass includes unlimited 3-hour rides. Based on this information, it is relatively unlikely for anyone, member or casual, to ride their bike for more than three hours, since they could just switch bikes earlier to avoid any additional costs. Also, it would be pointless for customers that regularly do trips lasting 2 1/3 hours or longer to switch to a membership, because at $0.16 per minute, a trip like that would already be more expensive than a day pass, even for a member that only starts paying after 45 minutes. Accordingly, unless Cyclistic are looking to change their membership conditions or pricing, these customers are not our targets for memberships. Yet, I will include every trip up to a duration of 4 hours. This will include people using a day pass and allows for a little margin on top of those three hours to include even those that perhaps miscalculated the duration of their trip or underestimated how long it would take them to get to the next station. However, I might subset the data set based on trip duration later in my analysis, depending on which question I am looking to answer.

# Copy of unchanged dataframe
data_old <- data

# Exclude trip durations of more than 4 hours
data <- data %>% filter(ride_duration <= 240)

# Changelog entry
changelog <- track_changes(data, data_old, changelog,
                           "removal", "duration_>_240min")

6 Wrap-up

6.1 Additional variables

Before summarizing the data again, let’s add some time related variables that will be interesting to analyze later.

# Add day of the week, weekend, weekm month, time of day and date
data <- data %>%
  mutate(day_of_week = lubridate::wday(started_at, label = T, week_start = 1),
         weekend = as.factor(ifelse(day_of_week %in% c("Sat", "Sun"),
                                    "weekend",
                                    "weekday")),
         week = lubridate::isoweek(started_at),
         month = lubridate::month(started_at, label = T, abbr = T),
         hour = lubridate::hour(started_at),
         minute = hour + lubridate::minute(started_at)/60,
         second = minute + lubridate::second(started_at)/3600,
         date = lubridate::date(started_at))

6.2 Summary and Changelog

Now we can summarize the resulting data set…

# Summarize final data set
summary(data)
##     ride_id              rideable_type       started_at
##  Min.   :      1   classic_bike :3194662   Min.   :2021-04-01 00:03:18.0
##  1st Qu.:1465136   electric_bike:2121520   1st Qu.:2021-06-23 21:16:06.0
##  Median :2913560                           Median :2021-08-19 20:51:46.5
##  Mean   :2896478                           Mean   :2021-08-28 15:30:50.2
##  3rd Qu.:4333891                           3rd Qu.:2021-10-16 16:39:39.0
##  Max.   :5723532                           Max.   :2022-03-31 23:59:47.0
##
##     ended_at                                    start_station_name
##  Min.   :2021-04-01 00:14:29.00   Streeter Dr & Grand Ave:  64425
##  1st Qu.:2021-06-23 21:35:18.25   Wells St & Concord Ln  :  41950
##  Median :2021-08-19 21:10:19.00   Clark St & Elm St      :  39352
##  Mean   :2021-08-28 15:47:39.69   Michigan Ave & Oak St  :  36958
##  3rd Qu.:2021-10-16 16:58:19.50   Wells St & Elm St      :  36304
##  Max.   :2022-04-01 00:27:32.00   (Other)                :4375139
##                                   NA's                   : 722054
##      start_station_id                  end_station_name
##  13022       :  64425   Streeter Dr & Grand Ave:  63326
##  TA1308000050:  41950   Wells St & Concord Ln  :  42280
##  LF-005      :  41657   Clark St & Elm St      :  38784
##  TA1307000039:  39352   Michigan Ave & Oak St  :  37074
##  13042       :  36958   Wells St & Elm St      :  36215
##  (Other)     :4369786   (Other)                :4339249
##  NA's        : 722054   NA's                   : 759254
##       end_station_id      start_lat       start_lng         end_lat
##  13022       :  63326   Min.   :41.64   Min.   :-87.84   Min.   :41.39
##  LF-005      :  47484   1st Qu.:41.88   1st Qu.:-87.66   1st Qu.:41.88
##  TA1308000050:  42280   Median :41.90   Median :-87.64   Median :41.90
##  TA1307000039:  38784   Mean   :41.90   Mean   :-87.65   Mean   :41.90
##  13042       :  37074   3rd Qu.:41.93   3rd Qu.:-87.63   3rd Qu.:41.93
##  (Other)     :4327980   Max.   :42.07   Max.   :-87.52   Max.   :42.17
##  NA's        : 759254
##     end_lng       member_casual    ride_duration     day_of_week
##  Min.   :-88.97   casual:2199527   Min.   :  1.000   Mon:683543
##  1st Qu.:-87.66   member:3116655   1st Qu.:  6.567   Tue:724709
##  Median :-87.64                    Median : 11.400   Wed:744932
##  Mean   :-87.65                    Mean   : 16.825   Thu:727881
##  3rd Qu.:-87.63                    3rd Qu.: 20.150   Fri:761306
##  Max.   :-87.49                    Max.   :239.967   Sat:888503
##                                                      Sun:785308
##     weekend             week          month              hour
##  weekday:3642371   Min.   : 1.0   Jul    : 749866   Min.   : 0.00
##  weekend:1673811   1st Qu.:22.0   Aug    : 745893   1st Qu.:11.00
##                    Median :30.0   Sep    : 707872   Median :15.00
##                    Mean   :29.6   Jun    : 663994   Mean   :14.21
##                    3rd Qu.:38.0   Oct    : 596885   3rd Qu.:18.00
##                    Max.   :52.0   May    : 478734   Max.   :23.00
##                                   (Other):1372938
##      minute          second           date
##  Min.   : 0.00   Min.   : 0.00   Min.   :2021-04-01
##  1st Qu.:11.42   1st Qu.:11.43   1st Qu.:2021-06-23
##  Median :15.55   Median :15.55   Median :2021-08-19
##  Mean   :14.70   Mean   :14.71   Mean   :2021-08-28
##  3rd Qu.:18.37   3rd Qu.:18.37   3rd Qu.:2021-10-16
##  Max.   :23.98   Max.   :24.00   Max.   :2022-03-31
## 

… and examine the changelog.

# Changelog by type and membership
changelog %>%
  group_by(type, member_casual) %>%
  summarize(trips = n()) %>%
  kable_custom()
type member_casual trips
modification casual 11163
modification member 16350
removal casual 347015
removal member 60335
# Changelog by type and bike type
changelog %>%
  group_by(type, rideable_type) %>%
  summarize(trips = n()) %>%
  kable_custom()
type rideable_type trips
modification classic_bike 20992
modification electric_bike 6521
removal classic_bike 56029
removal docked_bike 303989
removal electric_bike 47332
# Changelog by type, reason and membership
changelog %>%
  group_by(type, reason, member_casual) %>%
  summarize(trips = n()) %>%
  kable_custom(full_width = TRUE) %>%
  scroll_box(height = "300px", width = "500px")
type reason member_casual trips
modification fix_NA_station_names casual 3
modification ID_instead_of_name casual 2
modification multiple_IDs_for_same_station casual 11158
modification multiple_IDs_for_same_station member 16350
removal classic_and_no_end_station casual 1724
removal classic_and_no_end_station member 2539
removal docked_bikes casual 303989
removal duration_<_1min casual 33234
removal duration_<_1min member 54249
removal duration_>_240min casual 4700
removal duration_>_240min member 1764
removal identical_ID_and_name casual 29
removal identical_ID_and_name member 52
removal no_geographical_coordinates casual 3169
removal no_geographical_coordinates member 1052
removal warehouse casual 170
removal warehouse member 679
# Changelog by type, bike type and membership
changelog %>%
  group_by(type, rideable_type, member_casual) %>%
  summarize(trips = n()) %>%
  kable_custom(full_width = TRUE) %>%
  scroll_box(height = "300px", width = "400px")
type rideable_type member_casual trips
modification classic_bike casual 7539
modification classic_bike member 13453
modification electric_bike casual 3624
modification electric_bike member 2897
removal classic_bike casual 22048
removal classic_bike member 33981
removal docked_bike casual 303989
removal electric_bike casual 20978
removal electric_bike member 26354
# Changelog by type, reason, bike type and membership
changelog %>%
  group_by(type, reason, rideable_type, member_casual) %>%
  summarize(trips = n()) %>%
  kable_custom(full_width = TRUE) %>%
  scroll_box(height = "300px", width = "600px")
type reason rideable_type member_casual trips
modification fix_NA_station_names electric_bike casual 3
modification ID_instead_of_name electric_bike casual 2
modification multiple_IDs_for_same_station classic_bike casual 7539
modification multiple_IDs_for_same_station classic_bike member 13453
modification multiple_IDs_for_same_station electric_bike casual 3619
modification multiple_IDs_for_same_station electric_bike member 2897
removal classic_and_no_end_station classic_bike casual 1724
removal classic_and_no_end_station classic_bike member 2539
removal docked_bikes docked_bike casual 303989
removal duration_<_1min classic_bike casual 13289
removal duration_<_1min classic_bike member 29259
removal duration_<_1min electric_bike casual 19945
removal duration_<_1min electric_bike member 24990
removal duration_>_240min classic_bike casual 3793
removal duration_>_240min classic_bike member 942
removal duration_>_240min electric_bike casual 907
removal duration_>_240min electric_bike member 822
removal identical_ID_and_name classic_bike casual 14
removal identical_ID_and_name classic_bike member 37
removal identical_ID_and_name electric_bike casual 15
removal identical_ID_and_name electric_bike member 15
removal no_geographical_coordinates classic_bike casual 3169
removal no_geographical_coordinates classic_bike member 1052
removal warehouse classic_bike casual 59
removal warehouse classic_bike member 152
removal warehouse electric_bike casual 111
removal warehouse electric_bike member 527

6.3 Save

Finally, we need to save the clean data, so that we can use it in our analysis. We’ll also save the changelog to document and possibly communicate the cleaning process.

# Save clean data
save(data, file = "data/r_data/data_cleaned.rda")

# Save changelog
save(changelog, file = "data/r_data/changelog.rda")

Continue with part II here.