BikeShare Analysis

Introduction

For the purpose of this presentation we are interested in the factors that contribute to the frequency in which bicycles are rented from a bike share program. The data we will be using is sourced from Kaggle, though few notes are given on the origin of said data. The full source is available at https://www.kaggle.com/datasets/shrutipandit707/bikesharing.

We are interested in determining factors that can predict the total daily ridership of a specific day, given the weather conditions and what day we are preparing for.

The information provided in the dataset is difficult to parse at first glance and we will need to work on cleaning the data before we can begin analysis. A quick preview also reveals that many of the variables are unclear, often leaving discrete entries as numerical when explanatory text would be helpful. One seemingly incorrect detail is the workingday variable, later renamed to is_workday, which tracks whether or not a given day is a working day. Our assumption was that this indicates weekend days, but that is not the case, given the inconsistency the variable has when compared to the day of the week a specific date would be.

##   instant   dteday season yr mnth holiday weekday workingday weathersit
## 1       1 1/1/2018      1  0    1       0       6          0          2
## 2       2 2/1/2018      1  0    1       0       0          0          2
## 3       3 3/1/2018      1  0    1       0       1          1          1
## 4       4 4/1/2018      1  0    1       0       2          1          1
## 5       5 5/1/2018      1  0    1       0       3          1          1
## 6       6 6/1/2018      1  0    1       0       4          1          1
##        temp    atemp     hum windspeed casual registered  cnt
## 1 14.110847 18.18125 80.5833 10.749882    331        654  985
## 2 14.902598 17.68695 69.6087 16.652113    131        670  801
## 3  8.050924  9.47025 43.7273 16.636703    120       1229 1349
## 4       8.2  10.6061 59.0435 10.739832    108       1454 1562
## 5  9.305237  11.4635 43.6957   12.5223     82       1518 1600
## 6  8.378268 11.66045 51.8261 6.0008684     88       1518 1606

Data Cleaning

Several modifications to the data frame would benefit readability and improve foundation work later. Important changes include renaming the columns, unifying the date formatting, replacing the numbers in discrete variables, and truncating the digits for temperature, humidity, and wind speed.

# Rename each column
df <- df %>%
    rename(id           = instant,
           date         = dteday,
           season       = season,
           year         = yr,
           month        = mnth,
           is_holiday   = holiday,
           day_of_week  = weekday,
           is_workday   = workingday,
           weather      = weathersit,
           temperature  = temp,
           ambient_temp = atemp,
           humidity     = hum,
           wind_speed   = windspeed,
           casual_rides = casual,
           return_rides = registered,
           total_rides  = cnt) %>%
    # Modify column data
    mutate(# Unify date format
           date = lubridate::parse_date_time(date, orders = c('dmy')),
           # Convert discrete numbers to strings
           month        = month.name[month],
           year         = case_when(year == 0 ~ "2018",
                                    year == 1 ~ "2019"),
           season       = case_when(season == 1 ~ "Spring",
                                    season == 2 ~ "Summer",
                                    season == 3 ~ "Autumn",
                                    season == 4 ~ "Winter"),
           weather      = case_when(weather == 1 ~ "Clear",
                                    weather == 2 ~ "Overcast",
                                    weather == 3 ~ "Storm"),
           day_of_week  = wday(date, week_start=2,label=TRUE),
           is_holiday   = as.logical(is_holiday),
           is_workday   = as.logical(is_workday),
           # Vars temperature, ambient_temp, humidity, and wind_speed are characters
           # Convert them to doubles for use as continuous variables
           temperature  = as.double(temperature),
           ambient_temp = as.double(ambient_temp),
           humidity     = as.double(humidity),
           wind_speed   = as.double(wind_speed),
           # Convert relevant columns to factors
           year         = fct_relevel(year, "2018", "2019"),
           month        = fct_relevel(month, "January", "February", "March",
                                             "April", "May", "June", "July",
                                             "August", "September", "October",
                                             "November", "December"),
           season       = fct_relevel(season, "Winter", "Spring", "Summer", "Autumn"),
           weather      = fct_relevel(weather, "Clear", "Overcast", "Storm"),
           day_of_week  = fct_relevel(day_of_week, "Mon", "Tue", "Wed", "Thu",
                                                   "Fri", "Sat", "Sun"))

# Finally, preview the new data
head(df)
##   id       date season year   month is_holiday day_of_week is_workday  weather
## 1  1 2018-01-01 Spring 2018 January      FALSE         Mon      FALSE Overcast
## 2  2 2018-01-02 Spring 2018 January      FALSE         Tue      FALSE Overcast
## 3  3 2018-01-03 Spring 2018 January      FALSE         Wed       TRUE    Clear
## 4  4 2018-01-04 Spring 2018 January      FALSE         Thu       TRUE    Clear
## 5  5 2018-01-05 Spring 2018 January      FALSE         Fri       TRUE    Clear
## 6  6 2018-01-06 Spring 2018 January      FALSE         Sat       TRUE    Clear
##   temperature ambient_temp humidity wind_speed casual_rides return_rides
## 1   14.110847     18.18125  80.5833  10.749882          331          654
## 2   14.902598     17.68695  69.6087  16.652113          131          670
## 3    8.050924      9.47025  43.7273  16.636703          120         1229
## 4    8.200000     10.60610  59.0435  10.739832          108         1454
## 5    9.305237     11.46350  43.6957  12.522300           82         1518
## 6    8.378268     11.66045  51.8261   6.000868           88         1518
##   total_rides
## 1         985
## 2         801
## 3        1349
## 4        1562
## 5        1600
## 6        1606

Column Description

Variables Description
id A unique daily identifier
date The date data was collected
season The current season
year The current year
month The current month
is_holiday If the day was a holiday
day_of_week The day of the week
is_workday If the day was a workday
weather The weather conditions of a given day
temperature Reported daily temperature throughout the day
ambient_temp The ambient temperature throughout the day
humidity The humidity throughout the day
wind_speed The wind speed throughout the day
casual_rides How many non-registered users rented a bike
return_rides How many registered users rented a bike
total_rides Count of times a bike was rented on a given day

Data Exploration

Some quick plots of ride counts across several variables can give us a quick glance at which variables are worth investigating as good predictors of total_rides.

Categorical Data

df %>%
    ggplot(aes(x = season, y = total_rides, fill = season)) +
    geom_violin() +
    geom_boxplot(width=0.2, color="black", fill="white") +
    labs(x = "Season",
         y = "Daily Riders",
         title = "Distribution of Daily Ridership Between Seasons") +
    theme_bw() +
    theme(legend.position = "none")

df %>%
    ggplot(aes(x = weather, y = total_rides, fill = weather)) +
    geom_violin() +
    geom_boxplot(width=0.2, color="black", fill="white") +
    labs(x = "Weather",
         y = "Daily Riders",
         title = "Distribution of Daily Ridership By Daily Weather") +
    theme_bw() +
    theme(legend.position = "none")

df %>%
    ggplot(aes(x = day_of_week, y = total_rides, fill = day_of_week)) +
    geom_violin() +
    geom_boxplot(width=0.2, color="black", fill="white") +
    labs(x = "Day of the Week",
         y = "Daily Riders",
         title = "Distribution of Daily Ridership By Day of Week") +
    theme_bw() +
    theme(legend.position = "none")

df %>%
    ggplot(aes(x = month, y = total_rides, fill = month)) +
    geom_violin() +
    geom_boxplot(width=0.2, color="black", fill="white") +
    labs(x = "Month",
         y = "Daily Riders",
         title = "Distribution of Daily Ridership By Month") +
    theme_bw() +
    theme(legend.position = "none", axis.text.x = element_text(angle = 45, vjust = 1, hjust = 1))

ds <- df %>%
    select(season, month, is_holiday, day_of_week, is_workday, weather)
sapply(ds, function(x) table(x)/nrow(ds)*100)
## $season
## x
##   Winter   Spring   Summer   Autumn 
## 24.38356 24.65753 25.20548 25.75342 
## 
## $month
## x
##   January  February     March     April       May      June      July    August 
##  8.493151  7.671233  8.493151  8.219178  8.493151  8.219178  8.493151  8.493151 
## September   October  November  December 
##  8.219178  8.493151  8.219178  8.493151 
## 
## $is_holiday
## x
##     FALSE      TRUE 
## 97.123288  2.876712 
## 
## $day_of_week
## x
##      Mon      Tue      Wed      Thu      Fri      Sat      Sun 
## 14.38356 14.38356 14.24658 14.24658 14.24658 14.24658 14.24658 
## 
## $is_workday
## x
##    FALSE     TRUE 
## 31.64384 68.35616 
## 
## $weather
## x
##     Clear  Overcast     Storm 
## 63.424658 33.698630  2.876712

Categorical variables out of the way, the vast majority (97%) of our data points take place on non-holiday days, making it unlikely to be a good predictor for future total ride counts. However, we still need to explore our numerical variables.

Continuous Data

df %>%
    ggplot(aes(x = temperature, y = total_rides)) +
    geom_point() + 
    geom_smooth(method = "lm", se = TRUE, color = "red") +
    labs(x = "Actual Temperature (°C)",
         y = "Daily Riders",
         title = "Daily Ridership With Respect to Temperature") +
    theme_bw()
## `geom_smooth()` using formula 'y ~ x'

df %>%
    ggplot(aes(x = ambient_temp, y = total_rides)) +
    geom_point() + 
    geom_smooth(method = "lm", se = TRUE, color = "red") +
    labs(x = "Ambient Temperature (°C)",
         y = "Daily Riders",
         title = "Daily Ridership With Respect to Ambient Temperature") +
    theme_bw()
## `geom_smooth()` using formula 'y ~ x'

df %>%
    ggplot(aes(x = humidity, y = total_rides)) +
    geom_point() + 
    geom_smooth(method = "lm", se = TRUE, color = "red") +
    labs(x = "%Humidity",
         y = "Daily Riders",
         title = "Daily Ridership With Respect to Humidity") +
    theme_bw()
## `geom_smooth()` using formula 'y ~ x'

df %>%
    ggplot(aes(x = wind_speed, y = total_rides)) +
    geom_point() + 
    geom_smooth(method = "lm", se = TRUE, color = "red") +
    labs(x = "Wind Speed (km/h)",
         y = "Daily Riders",
         title = "Daily Ridership With Respect to Wind Speeds") +
    theme_bw()
## `geom_smooth()` using formula 'y ~ x'

From our plotting of the daily rider total against our numerical data, we see correlation between both actual and ambient temperatures and total daily rides. By combining our findings from our categorical and continuous exploration, we can start to look at building our first model.

Making a Model

Making a model is easy. Making a good model takes time. Sometimes the simplest of models is enough to make a sufficient prediction, taking a single variable and gauging how well it can predict a second variable. Let’s see how simple models fare at calculating daily ride totals.

season_rides = lm(total_rides ~ season, data=df)
summary(season_rides)
## 
## Call:
## lm(formula = total_rides ~ season, data = df)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -4706.2 -1067.6  -183.4  1219.4  5227.6 
## 
## Coefficients:
##              Estimate Std. Error t value Pr(>|t|)    
## (Intercept)    4728.2      117.6  40.193  < 2e-16 ***
## seasonSpring  -2119.8      165.9 -12.777  < 2e-16 ***
## seasonSummer    264.2      165.0   1.601     0.11    
## seasonAutumn    916.1      164.1   5.582 3.37e-08 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 1569 on 726 degrees of freedom
## Multiple R-squared:  0.3455, Adjusted R-squared:  0.3428 
## F-statistic: 127.7 on 3 and 726 DF,  p-value: < 2.2e-16
rain_rides = lm(total_rides ~ weather, data=df)
summary(rain_rides)
## 
## Call:
## lm(formula = total_rides ~ weather, data = df)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -4445.8 -1250.3   -13.8  1398.2  4317.2 
## 
## Coefficients:
##                 Estimate Std. Error t value Pr(>|t|)    
## (Intercept)      4876.79      85.54  57.011  < 2e-16 ***
## weatherOvercast  -831.97     145.22  -5.729 1.48e-08 ***
## weatherStorm    -3073.50     410.67  -7.484 2.09e-13 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 1841 on 727 degrees of freedom
## Multiple R-squared:  0.09858,    Adjusted R-squared:  0.0961 
## F-statistic: 39.75 on 2 and 727 DF,  p-value: < 2.2e-16
month_rides = lm(total_rides ~ month, data = df)
summary(month_rides)
## 
## Call:
## lm(formula = total_rides ~ month, data = df)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -5177.2 -1098.5  -242.1  1293.7  4669.7 
## 
## Coefficients:
##                Estimate Std. Error t value Pr(>|t|)    
## (Intercept)      2176.3      193.6  11.241  < 2e-16 ***
## monthFebruary     493.6      281.0   1.756   0.0794 .  
## monthMarch       1515.9      273.8   5.537 4.33e-08 ***
## monthApril       2308.6      276.1   8.362 3.20e-16 ***
## monthMay         3173.4      273.8  11.590  < 2e-16 ***
## monthJune        3596.0      276.1  13.025  < 2e-16 ***
## monthJuly        3387.3      273.8  12.371  < 2e-16 ***
## monthAugust      3488.1      273.8  12.739  < 2e-16 ***
## monthSeptember   3590.2      276.1  13.004  < 2e-16 ***
## monthOctober     3022.9      273.8  11.040  < 2e-16 ***
## monthNovember    2070.8      276.1   7.501 1.88e-13 ***
## monthDecember    1227.5      273.8   4.483 8.56e-06 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 1524 on 718 degrees of freedom
## Multiple R-squared:  0.3893, Adjusted R-squared:   0.38 
## F-statistic: 41.61 on 11 and 718 DF,  p-value: < 2.2e-16

While the single, categorical models look promising, running a sanity check by looking for a random distribution of residuals tells a different story.

plot(season_rides$residuals, ylab = "Ride Count ~ Season Model Residuals",
     main="Ride Count ~ Season Residual Distribution")

plot(rain_rides$residuals, ylab = "Ride Count ~ Weather Model Residuals",
     main="Ride Count ~ Weather Residual Distribution")

plot(month_rides$residuals, ylab = "Ride Count ~ Month Model Residual",
     main="Ride Count ~ Month Residual Distribution")

The plots of each model’s residuals have clear patterns, indicating that a more complex model is warranted. Given the jump in the middle of the chart, year might be more relevant than initially thought. Additionally, each variable tested above might have unexplored correlation.

Before moving on to multivariate linear regression, modeling how our continuous variables match to daily rides can give more indication on what factors correlate.

temp_rides = lm(total_rides ~ temperature, data = df)
summary(temp_rides)
## 
## Call:
## lm(formula = total_rides ~ temperature, data = df)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -4615.7 -1136.2   -98.7  1047.0  3736.0 
## 
## Coefficients:
##             Estimate Std. Error t value Pr(>|t|)    
## (Intercept) 1222.040    161.278   7.577 1.07e-13 ***
## temperature  161.717      7.446  21.719  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 1509 on 728 degrees of freedom
## Multiple R-squared:  0.3932, Adjusted R-squared:  0.3924 
## F-statistic: 471.7 on 1 and 728 DF,  p-value: < 2.2e-16
ambient_rides = lm(total_rides ~ ambient_temp, data = df)
summary(ambient_rides)
## 
## Call:
## lm(formula = total_rides ~ ambient_temp, data = df)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -4599.2 -1092.2   -92.8  1073.7  4378.8 
## 
## Coefficients:
##              Estimate Std. Error t value Pr(>|t|)    
## (Intercept)   953.518    171.385   5.564 3.71e-08 ***
## ambient_temp  149.812      6.832  21.928  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 1503 on 728 degrees of freedom
## Multiple R-squared:  0.3978, Adjusted R-squared:  0.3969 
## F-statistic: 480.8 on 1 and 728 DF,  p-value: < 2.2e-16
plot(temp_rides$residuals, ylab = "Ride Count ~ Temperature Model Residual",
     main="Ride Count ~ Temperature Residual Distribution")

plot(ambient_rides$residuals, ylab = "Ride Count ~ Ambient Temperature Model Residual",
     main="Ride Count ~ Ambient Temperature Residual Distribution")

Making Better Models

Where single-variable regression fails, multiple-variable regression saves the day. By introducing a more complex model, we both decrease the intuitiveness and increase the accuracy the model provides.

Our first attempt at a multivariate model uses ambient temperatures, the year, and the weather to predict a day’s total ride count.

multi_rides = lm(total_rides ~ temperature + year + weather, data = df)
summary(multi_rides)
## 
## Call:
## lm(formula = total_rides ~ temperature + year + weather, data = df)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -3278.3  -621.0   -25.1   779.3  2498.6 
## 
## Coefficients:
##                  Estimate Std. Error t value Pr(>|t|)    
## (Intercept)       708.531    120.006   5.904 5.45e-09 ***
## temperature       148.958      5.019  29.680  < 2e-16 ***
## year2019         2039.786     74.972  27.207  < 2e-16 ***
## weatherOvercast  -551.107     80.050  -6.885 1.26e-11 ***
## weatherStorm    -2135.185    226.275  -9.436  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 1009 on 725 degrees of freedom
## Multiple R-squared:  0.7299, Adjusted R-squared:  0.7284 
## F-statistic: 489.7 on 4 and 725 DF,  p-value: < 2.2e-16
plot(multi_rides$residuals, ylab = "Ride Count ~ Temperature + Year + Weather Residuals",
     main = "Ride Count ~ Temperature + Year + Weather Residual Distribution")

While the first attempt did produce a more accurate chart, nearly doubling the R-squared values of the previous, single-variable models, it still could be better. In particular, the residual chart seems to indicate a regular, repeating pattern. Including seasonality in our model improves the design once more.

The Final Model

all_rides = lm(total_rides ~ temperature + year + season + weather, data = df)
summary(all_rides)
## 
## Call:
## lm(formula = total_rides ~ temperature + year + season + weather, 
##     data = df)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -3886.9  -444.6    87.0   513.2  3457.5 
## 
## Coefficients:
##                  Estimate Std. Error t value Pr(>|t|)    
## (Intercept)      1954.161    152.787  12.790  < 2e-16 ***
## temperature       121.391      7.614  15.943  < 2e-16 ***
## year2019         2052.907     63.837  32.159  < 2e-16 ***
## seasonSpring    -1579.812     99.081 -15.945  < 2e-16 ***
## seasonSummer     -433.917     97.875  -4.433 1.07e-05 ***
## seasonAutumn     -645.202    125.766  -5.130 3.72e-07 ***
## weatherOvercast  -607.758     68.142  -8.919  < 2e-16 ***
## weatherStorm    -2408.860    193.095 -12.475  < 2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 857 on 722 degrees of freedom
## Multiple R-squared:  0.8059, Adjusted R-squared:  0.804 
## F-statistic: 428.3 on 7 and 722 DF,  p-value: < 2.2e-16
plot(all_rides$residuals, ylab = "Ride Count ~ Temp + Year + Season + Weather Residuals",
     main = "Ride Count ~ Temp + Year + Season + Weather Residual Distribution")

With a residual distribution that lacks a clear pattern, a p-value that is well below the standard 0.05 required for a 95% null-hypothesis rejection certainty, and an R-squared value at approximately 0.8, the new model incorporating the area’s temperature and weather alongside the record’s year and astronomical season provides a worthwhile prediction for the day’s total ride count.

Final Model Findings

Based on the final model, our top three predictor variables are:

Variable Description
Temperature A coefficient value of 121.391 indicates that a unit increase in temperature increases the day’s ride count by 121.391 rides.
Year A coefficient value of 2052.907 indicates that, with respect to 2018, the same day in the year 2019 will see an increase in the day’s ride count by 2052.907 rides.
(Stormy) Weather A coefficient value of -2408.860 indicates that, with respect to a sunny day, stormy weather will see 2408.860 less daily riders.

Put in common tongue, a bike rental company can expect more customers the longer they are in business and the warmer the day is. However, they can expect a noticeable drop in customers should the weather forecast predict rain.

Our remaining variables are also worth considering, winter brings in the most members, with respect to spring, summer, and autumn days. Finally, sunny weather sees the most foot traffic, with respect to both overcast and stormy weather days. For planning purposes, the rental company should expect high demands on sunny, warm, winter days, especially if the company has been operating in the area for over a year. On colder, cloudy, spring days newer bike rentals can expect lower ridership numbers.

Further Analysis

With our model complete, we can predict total ridership for a given date should we know the expected weather conditions in advance. However, there are more details we can explore, leaving questions for further analysis down the line. In future study we would like to investigate how weather conditions affect ridership between members and casual users.