uber<-fread("uber-data.csv",showProgress=FALSE)
#show the first 6 observations
knitr::kable(head(uber))
| Date/Time | Lat | Lon | Base |
|---|---|---|---|
| 9/1/2014 0:01 | 40.2201 | -74.0021 | B02512 |
| 9/1/2014 0:01 | 40.7500 | -74.0027 | B02512 |
| 9/1/2014 0:03 | 40.7559 | -73.9864 | B02512 |
| 9/1/2014 0:06 | 40.7450 | -73.9889 | B02512 |
| 9/1/2014 0:11 | 40.8145 | -73.9444 | B02512 |
| 9/1/2014 0:12 | 40.6735 | -73.9918 | B02512 |
#check the structure
str(uber)
## Classes 'data.table' and 'data.frame': 1028136 obs. of 4 variables:
## $ Date/Time: chr "9/1/2014 0:01" "9/1/2014 0:01" "9/1/2014 0:03" "9/1/2014 0:06" ...
## $ Lat : num 40.2 40.8 40.8 40.7 40.8 ...
## $ Lon : num -74 -74 -74 -74 -73.9 ...
## $ Base : chr "B02512" "B02512" "B02512" "B02512" ...
## - attr(*, ".internal.selfref")=<pointer: 0x000001a71b251230>
#summarize the data quality
initial_summary <- data.table(
observations = nrow(uber),
variables = ncol(uber),
missing_values = sum(is.na(uber)),
duplicate_rows = sum(duplicated(uber)),
unique_bases = uniqueN(uber$Base)
)
knitr::kable(initial_summary)
| observations | variables | missing_values | duplicate_rows | unique_bases |
|---|---|---|---|---|
| 1028136 | 4 | 0 | 24037 | 5 |
print(initial_summary)
## observations variables missing_values duplicate_rows unique_bases
## <int> <int> <int> <int> <int>
## 1: 1028136 4 0 24037 5
The “uber-data.csv” contains 1,028,136 observations and 4 variables (no missing values). With 24,037 duplicate records and 5 unique Uber base codes. I kept the duplicates because identical records may represent separate trips.
I am going to convert the date&time variable into a proper datetime format and check whether all timestamps are valid. Also check the latitude and longitude ranges to identify invalid geographical coordinates before spatial clustering.
#convert date and time column
uber[,datetime:=mdy_hm(`Date/Time`,tz="America/New_York")] #to use the NY local time zone
#check datetime validity & coverage
datetime_check<-uber[,.(
invalid_datetime = sum(is.na(datetime)),
first_timestamp = min(datetime, na.rm = TRUE),
last_timestamp = max(datetime, na.rm = TRUE)
)]
knitr::kable(datetime_check)
| invalid_datetime | first_timestamp | last_timestamp |
|---|---|---|
| 0 | 2014-09-01 | 2014-09-30 22:59:00 |
#check if geographic coordinate is valid
coordinate_check <- uber[, .(
invalid_coordinates = sum(
!is.finite(Lat) | !is.finite(Lon) |
Lat < -90 | Lat > 90 |
Lon < -180 | Lon > 180
),
min_latitude = min(Lat, na.rm = TRUE),
max_latitude = max(Lat, na.rm = TRUE),
min_longitude = min(Lon, na.rm = TRUE),
max_longitude = max(Lon, na.rm = TRUE)
)]
knitr::kable(coordinate_check)
| invalid_coordinates | min_latitude | max_latitude | min_longitude | max_longitude |
|---|---|---|---|---|
| 0 | 39.9897 | 41.3476 | -74.7736 | -72.7163 |
print(datetime_check)
## invalid_datetime first_timestamp last_timestamp
## <int> <POSc> <POSc>
## 1: 0 2014-09-01 2014-09-30 22:59:00
print(coordinate_check)
## invalid_coordinates min_latitude max_latitude min_longitude max_longitude
## <int> <num> <num> <num> <num>
## 1: 0 39.9897 41.3476 -74.7736 -72.7163
All timestamps were converted into datetime format, with 0 invalid values. The geographic coordinates are also valid, with latitude from 39.9897 to 41.3476 and longitude from -74.7736 to -72.7163.
This step, we extract the date, hour, and day of the week from the datetime variable to support temporal clustering analysis and forecasting. Then, we also check the observation period and hourly coverage to see incomplete days.
#create temporal variables
uber[,`:=`(
date = as.Date(datetime,tz="America/New_York"),
hour = lubridate::hour(datetime),
day_of_week = lubridate::wday(datetime, week_start = 1)
)]
#check first observations
knitr::kable(head(uber[, .(datetime, date, hour, day_of_week)]))
| datetime | date | hour | day_of_week |
|---|---|---|---|
| 2014-09-01 00:01:00 | 2014-09-01 | 0 | 1 |
| 2014-09-01 00:01:00 | 2014-09-01 | 0 | 1 |
| 2014-09-01 00:03:00 | 2014-09-01 | 0 | 1 |
| 2014-09-01 00:06:00 | 2014-09-01 | 0 | 1 |
| 2014-09-01 00:11:00 | 2014-09-01 | 0 | 1 |
| 2014-09-01 00:12:00 | 2014-09-01 | 0 | 1 |
#summarize the observation period
time_summary<-uber[, .(
first_timestamp = min(datetime),
last_timestamp = max(datetime),
total_dates = uniqueN(date),
total_hours = uniqueN(hour)
)]
print(time_summary)
## first_timestamp last_timestamp total_dates total_hours
## <POSc> <POSc> <int> <int>
## 1: 2014-09-01 2014-09-30 22:59:00 30 24
#check hourly coverage for each date
hour_coverage <- uber[, .(
trips = .N,
observed_hours = uniqueN(hour)
), by = date][order(date)]
#display dates with incomplete hourly coverage
print(hour_coverage[observed_hours < 24])
## date trips observed_hours
## <Date> <int> <int>
## 1: 2014-09-30 33431 23
#check original and converted datetime
print(head(uber[, .(original = `Date/Time`, parsed = datetime)]))
## original parsed
## <char> <POSc>
## 1: 9/1/2014 0:01 2014-09-01 00:01:00
## 2: 9/1/2014 0:01 2014-09-01 00:01:00
## 3: 9/1/2014 0:03 2014-09-01 00:03:00
## 4: 9/1/2014 0:06 2014-09-01 00:06:00
## 5: 9/1/2014 0:11 2014-09-01 00:11:00
## 6: 9/1/2014 0:12 2014-09-01 00:12:00
#check datetime validity
print(sum(is.na(uber$datetime)))
## [1] 0
As we can see, the dataset covers 30 days in September 2014, with observations across all 24hrs of the day. September 30 contains only 23 observed hours (33,431 records).
We use K-means to identify geographical groups of uber trips based on lat and lon. I believe K-means is suitable for this analysis because it provides cluster centers that can be used in the subsequent temporal analysis and forecasting.
To specify number of clusters, we compare different values of k using the Elbow Method, Silhouette Score, and Calinski–Harabasz Index. Since the dataset is large, model selection is performed on a reproducible random sample of training observations.
#select training observations
train_rows<-which(uber$date < as.Date("2014-09-24"))
#create a reproducible sample for model selection
set.seed(42)
sample_rows <- sample(train_rows, size = min(20000, length(train_rows)))
sample_data <- uber[sample_rows, .(Lon, Lat)]
#convert coordinates to a projected system in meters
sample_sf <- st_as_sf(
sample_data,
coords = c("Lon", "Lat"),
crs = 4326
)
sample_sf <- st_transform(sample_sf, crs = 32618)
X <- st_coordinates(sample_sf)
#select observations for Silhouette evaluation
set.seed(42)
sil_rows <- sample(seq_len(nrow(X)), size = 1000)
sil_dist <- dist(X[sil_rows, , drop = FALSE])
#test different numbers of clusters
k_values <- 2:12
k_results <- data.table::rbindlist(lapply(k_values, function(k) {
#fit K-means for the current number of clusters
model <- kmeans(X, centers = k, nstart = 10, iter.max = 500, algorithm = "Lloyd")
#calculate average Silhouette Score
sil <- cluster::silhouette(model$cluster[sil_rows], sil_dist)
avg_sil <- mean(sil[, "sil_width"])
#calculate Calinski-Harabasz Index
ch <- (model$betweenss / (k - 1)) /
(model$tot.withinss / (nrow(X) - k))
data.table::data.table(
k = k,
inertia = model$tot.withinss,
silhouette = avg_sil,
calinski_harabasz = ch
)
}))
#display quality measures
print(round(k_results, 3))
## k inertia silhouette calinski_harabasz
## <num> <num> <num> <num>
## 1: 2 626060409323 0.652 8664.566
## 2: 3 455702670263 0.387 9689.338
## 3: 4 369815930522 0.413 9507.310
## 4: 5 305865314267 0.439 9666.043
## 5: 6 244766108085 0.433 10660.835
## 6: 7 201839938490 0.435 11481.561
## 7: 8 170296582423 0.449 12192.632
## 8: 9 149005208970 0.407 12549.438
## 9: 10 136125041255 0.434 12420.100
## 10: 11 113977206844 0.434 13737.950
## 11: 12 104958181362 0.437 13717.689
#plot Elbow Method
ggplot(k_results, aes(x = k, y = inertia)) +
geom_line() +
geom_point() +
scale_x_continuous(breaks = k_values) +
labs(title = "Elbow Method", x = "Number of clusters",
y = "Within-Cluster Sum of Squares") +
theme_minimal()
#plot Silhouette Scores
ggplot(k_results, aes(x = k, y = silhouette)) +
geom_line() +
geom_point() +
scale_x_continuous(breaks = k_values) +
labs(title = "Silhouette Score by Number ofclusters",
x = "Number of Clusters", y = "Average Silhouette") +
theme_minimal()
#compare cluster sizes for k=5 & k=8
set.seed(42)
model_5 <- kmeans(X, centers = 5, nstart = 10,iter.max = 500, algorithm = "Lloyd")
model_8 <- kmeans(X, centers = 8, nstart = 10,iter.max = 500, algorithm = "Lloyd")
#calculate cluster sizes, percentages and display
print(size_5 <- data.table(
cluster = 1:5,
count = model_5$size,
percentage = round(100 * model_5$size / nrow(X), 2)
))
## cluster count percentage
## <int> <int> <num>
## 1: 1 660 3.30
## 2: 2 1368 6.84
## 3: 3 215 1.07
## 4: 4 7463 37.31
## 5: 5 10294 51.47
print(size_8 <- data.table(
cluster = 1:8,
count = model_8$size,
percentage = round(100 * model_8$size / nrow(X), 2)
))
## cluster count percentage
## <int> <int> <num>
## 1: 1 528 2.64
## 2: 2 215 1.07
## 3: 3 472 2.36
## 4: 4 2088 10.44
## 5: 5 6958 34.79
## 6: 6 73 0.36
## 7: 7 899 4.50
## 8: 8 8767 43.84
The Elbow Method doesn’t show a clear optimal number of clusters. Although k=2 gets the highest Silhouette score =0.652 (limited geographical detail).
Among the larger solutions, k=8 gets a slightly higher Silhouette Score =0.449 than k=5 0.439. However, k=8 also produces a very small cluster (only 0.36% of the sampled observations).
Both solutions show unequal cluster sizes. Therefore, for now we will keep k=5 and k=8 for further geographical evaluation.
We compare the clustering results for k=5 and k=8 using the Silhouette Score, Calinski–Harabasz Index, and within-cluster sum of squares.
#compare clustering quality
models<-list(model_5, model_8)
quality_results <- rbindlist(lapply(seq_along(models), function(i) {
model <- models[[i]]
k <- c(5, 8)[i]
#Silhouette Score
sil <- silhouette(model$cluster[sil_rows], sil_dist)
#Calinski-Harabasz Index
ch <- (model$betweenss / (k - 1)) /
(model$tot.withinss / (nrow(X) - k))
data.table(
k = k,
inertia = round(model$tot.withinss, 2),
silhouette = round(mean(sil[, "sil_width"]), 3),
calinski_harabasz = round(ch, 2)
)
}))
print(quality_results)
## k inertia silhouette calinski_harabasz
## <num> <num> <num> <num>
## 1: 5 300313928341 0.429 9937.13
## 2: 8 170296582423 0.449 12192.63
#extract cluster centers and sizes
centers <- rbindlist(list(
data.table(
solution = "k = 5",
cluster = 1:5,
x = model_5$centers[, 1],
y = model_5$centers[, 2],
count = model_5$size
),
data.table(
solution = "k = 8",
cluster = 1:8,
x = model_8$centers[, 1],
y = model_8$centers[, 2],
count = model_8$size
)
))
#convert cluster centers back to longitude and latitude
centers_sf <- st_as_sf(
centers, coords = c("x", "y"), crs = 32618
)
centers_sf <- st_transform(centers_sf, 4326)
center_coords <- st_coordinates(centers_sf)
centers[, `:=`(
Lon = center_coords[, 1],
Lat = center_coords[, 2],
percentage = round(100 * count / nrow(X), 2)
)]
#display cluster centers
print(centers[, .(
solution, cluster, Lon, Lat, count, percentage
)])
## solution cluster Lon Lat count percentage
## <char> <int> <num> <num> <int> <num>
## 1: k = 5 1 -73.76770 40.67029 660 3.30
## 2: k = 5 2 -73.88954 40.80268 1368 6.84
## 3: k = 5 3 -74.20406 40.69833 215 1.07
## 4: k = 5 4 -73.98668 40.70974 7463 37.31
## 5: k = 5 5 -73.98058 40.75778 10294 51.47
## 6: k = 8 1 -73.77873 40.65348 528 2.64
## 7: k = 8 2 -74.20406 40.69833 215 1.07
## 8: k = 8 3 -73.92343 40.84774 472 2.36
## 9: k = 8 4 -73.96666 40.67811 2088 10.44
## 10: k = 8 5 -73.97356 40.76582 6958 34.79
## 11: k = 8 6 -73.58872 40.90679 73 0.36
## 12: k = 8 7 -73.86748 40.76017 899 4.50
## 13: k = 8 8 -73.99452 40.72985 8767 43.84
#sample observations for geographic visualization
set.seed(42)
plot_rows <- sample(seq_len(nrow(X)), 7000)
plot_data <- rbindlist(list(
data.table(
Lon = sample_data$Lon[plot_rows],
Lat = sample_data$Lat[plot_rows],
cluster = factor(model_5$cluster[plot_rows]),
solution = "k = 5"
),
data.table(
Lon = sample_data$Lon[plot_rows],
Lat = sample_data$Lat[plot_rows],
cluster = factor(model_8$cluster[plot_rows]),
solution = "k = 8"
)
))
#visualize
ggplot(plot_data, aes(x = Lon, y = Lat, color = cluster)) +
geom_point(alpha = 0.4, size = 0.5) +
geom_point(
data = centers,
aes(x = Lon, y = Lat),
inherit.aes = FALSE,
shape = 4,
color = "black",
size = 3,
stroke = 1.2
) +
facet_wrap(~solution) +
coord_quickmap() +
labs(
title = "Geographical Clustering",
subtitle = "Black crosses indicate cluster centers",
x = "Longitude",
y = "Latitude",
color = "Cluster"
) +
theme_minimal()
k=8 solution gets a higher Silhouette Score 0.449 and Calinski–Harabasz
Index 12,192.63 than k=5 (0.429 and 9,937.13,)
The geographical visualization shows k=8 provides a more detailed spatial separation of uber trip locations.
Based on the results, I’ll choose k=8 for further analysis.
Use k=8 clustering model to the dataset using the established cluster centers then examine the number of trips in each cluster and visualize their geographical distribution to check the main areas of uber activity.
#project geographic coordinates
xy<-sf::sf_project("EPSG:4326", "EPSG:32618", as.matrix(uber[, .(Lon, Lat)]))
#assign observations to nearest cluster center
nearest_dist <- rep(Inf, nrow(uber))
uber[, cluster := NA_integer_]
for (i in 1:8) {
d <- (xy[, 1] - model_8$centers[i, 1])^2 + (xy[, 2] - model_8$centers[i, 2])^2
nearest <- d < nearest_dist
uber[nearest, cluster := i]
nearest_dist[nearest] <- d[nearest]
}
#remove temporary objects
rm(xy, d, nearest, nearest_dist)
#summarize cluster sizes and centers
final_summary <- uber[, .(trips = .N), by = cluster][order(cluster)]
final_summary[, percentage := round(100 * trips / sum(trips), 2)]
final_summary <- merge(final_summary, centers[solution == "k = 8", .(cluster, Lon, Lat)], by = "cluster")
print(final_summary)
## Key: <cluster>
## cluster trips percentage Lon Lat
## <int> <int> <num> <num> <num>
## 1: 1 29147 2.83 -73.77873 40.65348
## 2: 2 10203 0.99 -74.20406 40.69833
## 3: 3 24930 2.42 -73.92343 40.84774
## 4: 4 108247 10.53 -73.96666 40.67811
## 5: 5 359839 35.00 -73.97356 40.76582
## 6: 6 3452 0.34 -73.58872 40.90679
## 7: 7 43268 4.21 -73.86748 40.76017
## 8: 8 449050 43.68 -73.99452 40.72985
knitr::kable(final_summary, digits = 4)
| cluster | trips | percentage | Lon | Lat |
|---|---|---|---|---|
| 1 | 29147 | 2.83 | -73.7787 | 40.6535 |
| 2 | 10203 | 0.99 | -74.2041 | 40.6983 |
| 3 | 24930 | 2.42 | -73.9234 | 40.8477 |
| 4 | 108247 | 10.53 | -73.9667 | 40.6781 |
| 5 | 359839 | 35.00 | -73.9736 | 40.7658 |
| 6 | 3452 | 0.34 | -73.5887 | 40.9068 |
| 7 | 43268 | 4.21 | -73.8675 | 40.7602 |
| 8 | 449050 | 43.68 | -73.9945 | 40.7298 |
#sample observations for visualization
set.seed(42)
plot_final <- uber[sample.int(nrow(uber), 15000), .(Lon, Lat, cluster)]
#visualize final clusters and their centers
ggplot(plot_final, aes(x = Lon, y = Lat, color = factor(cluster))) +
geom_point(alpha = 0.3, size = 0.5) +
geom_point(data = final_summary, aes(x = Lon, y = Lat), inherit.aes = FALSE, shape = 4, color = "black", size = 3) +
geom_text(data = final_summary, aes(x = Lon, y = Lat, label = cluster), inherit.aes = FALSE, nudge_y = 0.025, size = 3) +
coord_quickmap() +
labs(title = "Clustering k=8", x = "Longitude", y = "Latitude", color = "Cluster") +
theme_minimal()
k=8 model was applied to all 1,028,136 uber observations. Clusters 5
& 8 contain the majority of trips 78.68%, showing a strong
concentration of uber activity in central New York City. 6 is the
smallest 0.34% and represents a more distant geographical area. Overall,
the results reveal an uneven spatial distribution of uber trips across
the eight clusters.
We analyze uber trip activity and the geographical centers of the 8 clusters across different hrs of the day. For each cluster and hr, we calculate the number of trips and the average geographical location and also measure how far these hourly centers are from the original cluster centers to identify temporal changes in spatial activity.
#convert coordinates to meters
xy<-sf::sf_project("EPSG:4326", "EPSG:32618", as.matrix(uber[, .(Lon, Lat)]))
uber[, `:=`(x = xy[, 1], y = xy[, 2])]
rm(xy)
#hourly cluster centers
hourly_centers <- uber[, .(trips = .N, center_x = mean(x), center_y = mean(y)), by = .(cluster, hour)][order(cluster, hour)]
#distance from the original cluster center
hourly_centers[, shift_km := sqrt((center_x - model_8$centers[cluster, 1])^2 + (center_y - model_8$centers[cluster, 2])^2) / 1000]
#identify the busiest hour for each cluster
peak_hours <- hourly_centers[, .SD[which.max(trips)], by = cluster][, .(cluster, peak_hour = hour, peak_trips = trips)]
print(peak_hours)
## cluster peak_hour peak_trips
## <int> <int> <int>
## 1: 1 14 2468
## 2: 2 15 845
## 3: 3 21 1663
## 4: 4 21 6628
## 5: 5 17 29238
## 6: 6 17 238
## 7: 7 20 3177
## 8: 8 18 32768
#summarize hourly activity and center shifts
hourly_summary <- hourly_centers[, .(min_trips = min(trips), max_trips = max(trips), mean_shift_km = round(mean(shift_km), 2)), by = cluster]
print(hourly_summary)
## cluster min_trips max_trips mean_shift_km
## <int> <int> <int> <num>
## 1: 1 90 2468 0.88
## 2: 2 37 845 1.16
## 3: 3 279 1663 0.48
## 4: 4 1453 6628 0.36
## 5: 5 2266 29238 0.27
## 6: 6 24 238 2.95
## 7: 7 280 3177 0.49
## 8: 8 5072 32768 0.11
#hourly trip activity
ggplot(hourly_centers, aes(x = hour, y = factor(cluster), fill = trips)) +
geom_tile() +
scale_x_continuous(breaks = seq(0, 23, by = 2)) +
labs(title = "Hourly uber trip activity by cluster", x = "hour", y = "cluster", fill = "trips") +
theme_minimal()
#hourly center shifts
ggplot(hourly_centers, aes(x = hour, y = shift_km, color = factor(cluster))) +
geom_line() + geom_point(size = 0.8) +
facet_wrap(~cluster, scales = "free_y") +
scale_x_continuous(breaks = seq(0, 23, by = 6)) +
labs(title = "Hourly changes in cluster centers", x = "hour", y = "distance from original center (km)") +
theme_minimal() + theme(legend.position = "none")
It shows uber trip activity varies across clusters throughout the day.
Clusters 5 & 8 show the highest activity (peak at 17:00 and 18:00),
while clusters 3 and 4 peak at 21:00.
The geographical centers also show different levels of hourly variation. Cluster 8 keeps stable (average 0.11km), while cluster 6 has the largest variation (2.95 km), probably due to its smaller number of observations.
Similar to section (a), we analyze daily uber trip activity and changes in cluster centers from September 1 to 30, 2014.
#daily cluster centers
daily_centers <- uber[, .(trips = .N, center_x = mean(x), center_y = mean(y)), by = .(cluster, date)][order(cluster, date)]
#distance from the original cluster center
daily_centers[, shift_km := sqrt((center_x - model_8$centers[cluster, 1])^2 + (center_y - model_8$centers[cluster, 2])^2) / 1000]
#identify the busiest date for each cluster
peak_dates <- daily_centers[, .SD[which.max(trips)], by = cluster][, .(cluster, peak_date = date, peak_trips = trips)]
print(peak_dates)
## cluster peak_date peak_trips
## <int> <Date> <int>
## 1: 1 2014-09-01 1530
## 2: 2 2014-09-01 445
## 3: 3 2014-09-27 1133
## 4: 4 2014-09-13 5686
## 5: 5 2014-09-16 15027
## 6: 6 2014-09-27 176
## 7: 7 2014-09-21 1929
## 8: 8 2014-09-13 20725
#summarize daily activity and center shifts
daily_summary <- daily_centers[, .(total_trips = sum(trips), mean_daily_trips = round(mean(trips), 1), mean_shift_km = round(mean(shift_km), 2)), by = cluster]
print(daily_summary)
## cluster total_trips mean_daily_trips mean_shift_km
## <int> <int> <num> <num>
## 1: 1 29147 971.6 0.39
## 2: 2 10203 340.1 0.93
## 3: 3 24930 831.0 0.41
## 4: 4 108247 3608.2 0.15
## 5: 5 359839 11994.6 0.11
## 6: 6 3452 115.1 2.89
## 7: 7 43268 1442.3 0.31
## 8: 8 449050 14968.3 0.06
#daily trip activity
ggplot(daily_centers, aes(x = date, y = trips, color = factor(cluster))) +
geom_line() + geom_point(size = 0.6) +
facet_wrap(~cluster, scales = "free_y") +
scale_x_date(date_breaks = "7 days", date_labels = "%d %b") +
labs(title = "Daily uber trip activity by cluster", x = "date", y = "number of trips") +
theme_minimal() + theme(legend.position = "none")
#daily center shifts
ggplot(daily_centers, aes(x = date, y = shift_km, color = factor(cluster))) +
geom_line() + geom_point(size = 0.6) +
facet_wrap(~cluster, scales = "free_y") +
scale_x_date(date_breaks = "7 days", date_labels = "%d %b") +
labs(title = "Daily changes in cluster centers", x = "date", y = "Distance from Original Center (km)") +
theme_minimal() + theme(legend.position = "none")
It shows recurring fluctuations, with clusters 5 & 8 accounting for most observations. 8 has the most stable geographical center (0.06 km average shift), while 6 shows the largest variation (2.89 km), likely due to fewer observations.
For this data preparation step, we prepare hourly uber trip counts for each of the 8 geographical clusters and split the data into training (September 1–23) and testing (September 24–30) sets. Missing cluster-hour combinations are filled with 0, while hrs not observed in the original dataset are excluded to avoid misleading forecast evaluations.
#count hourly trips for each cluster
trip_counts<-uber[, .(trips = .N), by = .(cluster, date, hour)]
#create a complete hourly grid for all clusters
observed_hours <- unique(uber[, .(date, hour)])
forecast_data <- CJ(cluster = sort(unique(uber$cluster)), date = sort(unique(uber$date)), hour = 0:23)
#exclude hours missing from the original dataset
forecast_data <- merge(forecast_data, observed_hours, by = c("date", "hour"))
#merge trip counts and fill missing cluster-hour counts with 0
forecast_data <- merge(forecast_data, trip_counts, by = c("cluster", "date", "hour"), all.x = TRUE)
forecast_data[is.na(trips), trips := 0L]
setorder(forecast_data, cluster, date, hour)
#split data into training and testing
train_data <- forecast_data[date < as.Date("2014-09-24")]
test_data <- forecast_data[date >= as.Date("2014-09-24")]
#summarize training and testing
split_summary <- rbindlist(list(
train_data[, .(period = "Train", first_date = min(date), last_date = max(date), hourly_rows = .N, trips = sum(trips))],
test_data[, .(period = "Test", first_date = min(date), last_date = max(date), hourly_rows = .N, trips = sum(trips))]
))
print(split_summary)
## period first_date last_date hourly_rows trips
## <char> <Date> <Date> <int> <int>
## 1: Train 2014-09-01 2014-09-23 4416 789372
## 2: Test 2014-09-24 2014-09-30 1336 238764
#check hourly coverage
print(forecast_data[, .(observed_hours = uniqueN(hour)), by = date][observed_hours < 24])
## date observed_hours
## <Date> <int>
## 1: 2014-09-30 23
#check aggregated trip counts
stopifnot(sum(forecast_data$trips) == nrow(uber), !anyNA(forecast_data$trips))
Training (789,372 trips) and testing (238,764 trips). Hourly observations were prepared for all 8 clusters, with zero-trip hours included where appropriate. The incomplete hr on September 30 was excluded to ensure valid forecast evaluation.
We forecast hourly uber trip counts for each cluster using 2 methods (Seasonal Naive and Historical Hourly Profile). The first one uses observations from the same hr of the last week, while the second uses historical averages by cluster, weekday and hr (both use only training data to predict trips in the testing period).
#seasonal naive forecast using the previous week
naive_lookup <- train_data[, .(cluster, date = date + 7L, hour, pred_naive = trips)]
#add weekday information
train_data[, weekday := lubridate::wday(date, week_start = 1)]
test_data[, weekday := lubridate::wday(date, week_start = 1)]
#historical hourly averages
hourly_profile <- train_data[, .(pred_profile = mean(trips)), by = .(cluster, weekday, hour)]
#generate forecasts for the testing period
forecast_results <- merge(test_data, naive_lookup, by = c("cluster","date","hour"), all.x = TRUE)
forecast_results <- merge(forecast_results, hourly_profile, by = c("cluster","weekday","hour"), all.x = TRUE)
forecast_results[, pred_profile := round(pred_profile)]
setorder(forecast_results, cluster, date, hour)
#check results
stopifnot(nrow(forecast_results) == nrow(test_data), !anyNA(forecast_results[, .(pred_naive, pred_profile)]))
#show actual vs predicted trips
print(head(forecast_results[, .(cluster, date, hour, trips, pred_naive, pred_profile)], 12))
## cluster date hour trips pred_naive pred_profile
## <int> <Date> <int> <int> <int> <num>
## 1: 1 2014-09-24 0 8 6 10
## 2: 1 2014-09-24 1 4 3 2
## 3: 1 2014-09-24 2 2 0 1
## 4: 1 2014-09-24 3 2 3 4
## 5: 1 2014-09-24 4 26 13 16
## 6: 1 2014-09-24 5 36 31 42
## 7: 1 2014-09-24 6 39 32 44
## 8: 1 2014-09-24 7 23 26 21
## 9: 1 2014-09-24 8 19 15 17
## 10: 1 2014-09-24 9 21 18 20
## 11: 1 2014-09-24 10 21 20 24
## 12: 1 2014-09-24 11 23 20 23
Both methods generated hourly trip forecasts for all 8 clusters during the testing. All predictions are available, and they use only historical training data.
We’re going to use MAE, RMSE and WAPE to evaluate the forecasting accuracy of Seasonal Naive and Historical Hourly Profile. Then, compare actual and predicted hourly trip counts to see which method performs better during the testing.
#forecasting error metrics
evaluate<-function(pred, label) {
err <- forecast_results$trips - pred
data.table(method = label, MAE = round(mean(abs(err)), 2),
RMSE = round(sqrt(mean(err^2)), 2),
WAPE = round(100 * sum(abs(err)) / sum(forecast_results$trips), 2))
}
#compare forecasting methods
evaluation <- rbindlist(list(
evaluate(forecast_results$pred_naive, "Seasonal Naive"),
evaluate(forecast_results$pred_profile, "Historical hourly profile")
))
print(evaluation)
## method MAE RMSE WAPE
## <char> <num> <num> <num>
## 1: Seasonal Naive 28.46 66.95 15.93
## 2: Historical hourly profile 25.30 55.02 14.16
knitr::kable(evaluation)
| method | MAE | RMSE | WAPE |
|---|---|---|---|
| Seasonal Naive | 28.46 | 66.95 | 15.93 |
| Historical hourly profile | 25.30 | 55.02 | 14.16 |
#aggregate actual and predicted trips across clusters
plot_forecast <- forecast_results[, .(Actual = sum(trips),
Seasonal_Naive = sum(pred_naive),
Historical_Profile = sum(pred_profile)), by = .(date, hour)]
#create hourly timestamps
plot_forecast[, datetime := as.POSIXct(paste(date, sprintf("%02d:00:00", hour)),
format = "%Y-%m-%d %H:%M:%S", tz = "America/New_York")]
#prepare data
plot_long <- melt(plot_forecast, id.vars = "datetime",
measure.vars = c("Actual", "Seasonal_Naive", "Historical_Profile"),
variable.name = "method", value.name = "trips")
## Warning in melt.data.table(plot_forecast, id.vars = "datetime", measure.vars =
## c("Actual", : 'measure.vars' [Actual, Seasonal_Naive, Historical_Profile, ...]
## are not all of the same type. By order of hierarchy, the molten data value
## column will be of type 'double'. All measure variables not of type 'double'
## will be coerced too. Check DETAILS in ?melt.data.table for more on coercion.
#visualize actual vs predicted trips
ggplot(plot_long, aes(x = datetime, y = trips, color = method)) +
geom_line(linewidth = 0.6) +
scale_x_datetime(date_breaks = "1 day", date_labels = "%d %b") +
labs(title = "Actual vs Predicted hourly uber trips",
x = "Date", y = "Number of trips", color = "Method") +
theme_minimal()
The Historical Hourly Profile performs better than Seasonal Naive, with lower MAE (25.30), RMSE (55.02) and WAPE (14.16%). Both methods capture hourly trip patterns, but forecasting accuracy is limited by the short period.