Pada tahap ini, akan dilakukan forecast untuk sebuah perusahaan ride-sharing. Forecast akan dilakukan menggunakan time series dari data historis permintaan yang akan dianalisa menggunakan single seasonality dan multiple seasonality.
Pada tahap ini, saya akan memuat library-library yang dibutuhkan untuk analisa ini.
Di tahap ini kita akan membaca dataset dan melakukan glimpse() untuk melihat gambaran mengenai data ini.
## Observations: 90,113
## Variables: 16
## $ id <fct> 59d005e1ffcfa261708ce9cd, 59d0066affcfa261708ceb11…
## $ trip_id <fct> 59d005e9cb564761a8fe5d3e, NA, 59d006c131e39c618969…
## $ driver_id <fct> 59a892c5568be44b2734f276, NA, 599dc0dfa5b4fd5471ad…
## $ rider_id <fct> 59ad2d6efba75a581666b506, 59cd704bcf482f6ce2fadfdb…
## $ start_time <fct> 2017-10-01T00:00:17Z, 2017-10-01T00:02:34Z, 2017-1…
## $ src_lat <dbl> 41.07047, 41.07487, 41.04995, 41.05287, 41.06760, …
## $ src_lon <dbl> 29.01945, 28.99528, 29.03107, 28.99522, 28.98827, …
## $ src_area <fct> sxk9, sxk9, sxk9, sxk9, sxk9, sxk9, sxk9, sxk9, sx…
## $ src_sub_area <fct> sxk9s, sxk9e, sxk9s, sxk9e, sxk9e, sxk9s, sxk9s, s…
## $ dest_lat <dbl> 41.11716, 41.08351, 41.04495, 41.08140, 41.02125, …
## $ dest_lon <dbl> 29.03650, 29.00228, 28.98192, 28.98197, 29.11316, …
## $ dest_area <fct> sxk9, sxk9, sxk9, sxk9, sxk9, sxk9, sxk9, sxk9, sx…
## $ dest_sub_area <fct> sxk9u, sxk9e, sxk9e, sxk9e, sxk9q, sxk9e, sxk9e, s…
## $ distance <dbl> 5.379250, 1.126098, 4.169492, 3.358296, 11.693573,…
## $ status <fct> confirmed, nodrivers, confirmed, confirmed, nodriv…
## $ confirmed_time_sec <int> 8, 0, 32, 65, 0, 27, 32, 30, 0, 24, 9, 0, 118, 106…
Pada analisa ini, kita hanya akan menggunakan dua variabel, yaitu variabel area dan waktu.
| Name | scotty |
| Number of rows | 90113 |
| Number of columns | 2 |
| _______________________ | |
| Column type frequency: | |
| factor | 2 |
| ________________________ | |
| Group variables | None |
Variable type: factor
| skim_variable | n_missing | complete_rate | ordered | n_unique | top_counts |
|---|---|---|---|---|---|
| start_time | 0 | 1 | FALSE | 88729 | 201: 4, 201: 3, 201: 3, 201: 3 |
| src_sub_area | 0 | 1 | FALSE | 3 | sxk: 40869, sxk: 24732, sxk: 24512 |
src_sub_area adalah variabel yang berisi lokasi dan dalam data ini hanya ada tiga macam saja. Sedangkan variabel start_time memiliki format yang salah. Oleh sebab itu, kita akan menggunakan fungsi dari lubridate untuk mengubah formatnya.
| Name | scotty |
| Number of rows | 90113 |
| Number of columns | 2 |
| _______________________ | |
| Column type frequency: | |
| factor | 1 |
| POSIXct | 1 |
| ________________________ | |
| Group variables | None |
Variable type: factor
| skim_variable | n_missing | complete_rate | ordered | n_unique | top_counts |
|---|---|---|---|---|---|
| src_sub_area | 0 | 1 | FALSE | 3 | sxk: 40869, sxk: 24732, sxk: 24512 |
Variable type: POSIXct
| skim_variable | n_missing | complete_rate | min | max | median | n_unique |
|---|---|---|---|---|---|---|
| start_time | 0 | 1 | 2017-10-01 00:00:17 | 2017-12-02 23:59:20 | 2017-11-07 14:39:07 | 88729 |
Format POSIXctadalah format untuk tanggal. Jadi saat ini data kita sudah dalam format yang benar.
Variabel start_time adalah waktu ketika penumpang melakukan permintaan. Dalam data aslinya, data tanggal ini terdiri dari tanggal, bulan, tahun serta jam, menit, detik. Dalam tahap ini kita akan membuat variabel baru yaitu datetime dimana kita akan membulatkan komponen waktu ke dalam satuan jam. Hal ini berguna untuk memudahkan analisa data, karena tidak masuk akal untuk memprediksi dengan pola menit atau pola detik dalam hal ini.
Untuk membulatkan waktu ke dalam satuan jam, kita akan menggunakan fungsi floor_date() dari library lubridate. Lalu kita akan menjumlahkan pembulatan-pembulatan tadi sehingga tidak ada datetime yang ganda.
scotty %<>%
mutate(datetime = floor_date(x = .$start_time, unit = "hour")) %>%
group_by(src_sub_area, datetime) %>%
summarise(demand = n())
print(scotty)## # A tibble: 4,225 x 3
## # Groups: src_sub_area [3]
## src_sub_area datetime demand
## <fct> <dttm> <int>
## 1 sxk97 2017-10-01 00:00:00 6
## 2 sxk97 2017-10-01 01:00:00 4
## 3 sxk97 2017-10-01 02:00:00 9
## 4 sxk97 2017-10-01 03:00:00 2
## 5 sxk97 2017-10-01 04:00:00 5
## 6 sxk97 2017-10-01 05:00:00 4
## 7 sxk97 2017-10-01 06:00:00 1
## 8 sxk97 2017-10-01 08:00:00 3
## 9 sxk97 2017-10-01 09:00:00 3
## 10 sxk97 2017-10-01 10:00:00 3
## # … with 4,215 more rows
Salah satu karakteristik untuk analisa data timeseries adalah periode waktu yang lengkap, tidak ada periode waktu yang tidak tersedia untuk masing-masing wilayah. Maka, kita harus memastikan jumlah data per masing-masing src_sub_area memiliki jumlah yang sama.
Jumlah data demand untuk tiap area memiliki jumlah yang berbeda. Data timeseries harus tersedia setiap jam supaya model menjadi akurat. Sehingga apabila data pada jam tertentu tidak tersedia, maka data yang kosong tersebut harus dilengkapi dengan mengimputasinya dengan menggunakan fungsi complete dan seq lalu apabila pada jam tertentu terdapat data yang NA, kita akan mengisinya dengan NOL (0).
scotty %<>%
complete(datetime = seq(min(scotty$datetime), max(scotty$datetime), by = "hour")) %>%
mutate(demand = ifelse(is.na(demand), 0, demand))
scotty %>%
group_by(src_sub_area) %>%
summarise(demand = n())Setelah melakukan imputasi dengan fungsi pad, jumlah data tiap kategori menjadi sama.
Salah satu kriteria data timeseries yang lain adalah data yang berurut berdasarkan datetime-nya. Oleh sebab itu kita akan mengurutkannya terlebih dahulu.
Tahap pre-process sudah selesai, tetapi kita akan memastikan sekali lagi untuk memastikan bahwa data kita sudah tidak ada yang NA.
## [1] 0
scotty %>%
ggplot(aes(x = datetime, y=demand)) +
geom_line(aes(col = src_sub_area)) +
facet_wrap(~ src_sub_area, nrow = 3) +
theme(legend.position = "none")Sekarang kita akan melakukan dekomposisi untuk mengecek error, trend dan seasonal dari data timeseries ini.
scotty %>% filter(src_sub_area == "sxk97") %>%
.$demand %>% ts(frequency = 24) %>% decompose() %>% autoplot() +
labs(title = "DEKOMPOSISI HARIAN") + theme(plot.title = element_text(hjust = 0.5))Mengecek dengan dekomposisi mingguan.
scotty %>% filter(src_sub_area == "sxk97") %>%
.$demand %>% ts(frequency = 24*7) %>% decompose() %>% autoplot() +
labs(title = "DEKOMPOSISI MINGGUAN") + theme(plot.title = element_text(hjust = 0.5))Dekomposisi dengan menggunakan Multi-Sesonality, yaitu menggabungkan harian dengan mingguan.
scotty %>% filter(src_sub_area == "sxk97") %>%
.$demand %>% msts(seasonal.periods = c(24, 24*7)) %>% mstl() %>% autoplot() +
labs(title = "DEKOMPOSISI HARIAN dan MINGGUAN") + theme(plot.title = element_text(hjust = 0.5))Di tahap ini, kita akan membagi data kita menjadi data train dan data test, dimana data test adalah satu minggu terakhir dari data secara keseluruhan.
min_datetime <- min(scotty$datetime)
max_datetime <- max(scotty$datetime)
test_start <- max_datetime - hours(24*7) + hours(1)
train_end <- test_start - hours(1)
interval_train <- interval(min_datetime, train_end)
interval_test <- interval(test_start, max_datetime)Setelah menentukan interval data train dan data test, kita akan membuat suatu kolom baru bernama split untuk memberi tanda jika observasi pada baris itu adalah train atau test.
scotty %<>%
mutate(split = case_when(
datetime %within% interval_train ~ "train",
datetime %within% interval_test ~ "test"
))Selanjutnya kita akan melakukan nesting pada data kita. Nesting itu seperti tabel di dalam tabel, seperti berlapis. Pada tahap ini kita akan melakukan nesting pada datetime dan demand lalu akan mengubahnya menjadi data format wide dengan fungsi pivot_wider.
scotty %<>%
group_by(src_sub_area, split) %>%
nest(data = c(datetime, demand)) %>%
pivot_wider(names_from = split, values_from = data)
head(scotty)Pada Exploratory Data Analysis sebelumnya, kita bisa lihat bahwa data kita bisa direpresentasikan dengan sesonal harian ataupun seasonal mingguan. Pada tahap ini kita akan membuat suatu fungsi untuk merepresentasikan kedua jenis seasonal tad.
ts_function <- list(
ts = function(x) ts(x$demand, frequency = 24), #harian
msts = function(x) msts(x$demand, seasonal.periods = c(24, 24*7)) #mingguan
)
ts_function %<>%
rep(length(unique(scotty$src_sub_area))) %>%
enframe("ts_func_name", "ts_func") %>%
mutate(src_sub_area= sort(rep(unique(scotty$src_sub_area), length(unique(.$ts_func_name)))))
ts_functionMenggabungkan menjadi satu dataframe.
## Joining, by = "src_sub_area"
Seperti kita ketahui bahwa ada banyak jenis permodelan time-series yang bisa digunakan untuk memprediksi masa depan. Pada tahap ini, kita akan membuat suatu list yang berisi fungsi-fungsi yang umumnya digunakan untuk forecast.
model_list <- list(
stlm = function(x) stlm(x),
tbats = function(x) tbats(x),
HoltWinters = function(x) HoltWinters(x),
autoarima = function(x) auto.arima(x),
arfima = function(x) arfima(x),
ets = function(x) ets(x)
)
model_list %<>%
rep(length(unique(scotty$src_sub_area))) %>%
enframe("model_name", "model_func") %>%
mutate(src_sub_area = sort(rep(unique(scotty$src_sub_area), length(unique(.$model_name)))))
model_listMenggabungkan dengan dataframe sebelumnya, sehingga kita akan memiliki data, time-series dan permodelan dalam satu dataframe.
## Joining, by = "src_sub_area"
Permodelan autoarima dengan ets tidak bisa digunakan untuk multiple seasonality time-series, maka kita akan membuangnya.
Pada tahap ini, kita akan mengeksekusi model dengan fungsi map() dan invoke_map() dari package purr.
Disini kita akan melakukan forecast dan mengevaluasi model yang sudah dibuat menggunakan MAE (Mean Absolute Erorr) dengan fungsi mae_vec dari package yardstick.
scotty %>%
mutate(MAE_test = map(fitted, ~forecast(.x, h = 24*7)) %>%
map2_dbl(test, ~mae_vec(truth = .y$demand, estimate = .x$mean))) %>%
select(-c("ts_func", "model_func", "params", "data", "fitted")) %>%
arrange(src_sub_area, MAE_test)RECIPES dan Outlier TreatmentSaya akan membaca data ulang supaya bisa mulai dari data fresh lalu mengubahnya ke bentuk data wide.
Disini saya akan melakukan treatment untuk outlier dengan teknik Winsorize, yaitu memotong data ekstrim dan menggantinya dengan angka yang kurang ekstrim. Dalam kasus ini, saya memotong 2.5% demand tertinggi dari data train dan menggantinya dengan angka pada data ke 97.5%. Winsorizing akan dilakukan menggunakan fungsi Winsorize() dari package DescTools.
scotty <- scotty_improve
scotty %<>% mutate(split = case_when(
datetime %within% interval_train ~ "train",
datetime %within% interval_test ~ "test"))
scotty_train <- scotty %>% filter(split == "train")
scotty_test <- scotty %>% filter(split == "test")
scotty_train$demand <- DescTools::Winsorize(scotty_train$demand, probs = c(0.0, 0.975))
scotty <- rbind(scotty_train, scotty_test)
scotty %<>%
arrange(src_sub_area, datetime)
scotty_improve <- scotty
scotty_improve %<>% spread(src_sub_area, demand) %>% select(-split)Pada tahap ini saya akan mencoba untuk melakukan preprocessing data yang sebelumnya belum dilakukan. Pada tahap ini akan digunakan package recipes untuk melakukan preprocessing data.
Disini saya mendefinisikan preprocess recipe() dan bake() pada data kita dengan recipe yang didefinisikan. Data akan dinormalisai dengan mengakarkan data, lalu center dan scale.
scotty_recipe_improve <- recipe(~., filter(scotty_improve, datetime %within% interval_train)) %>%
step_sqrt(all_numeric()) %>%
step_center(all_numeric()) %>%
step_scale(all_numeric()) %>%
prep()Lalu mengaplikasikan recipe pada data.
scotty_data_recipe_improve <- bake(scotty_recipe_improve, scotty_improve)
scotty_data_recipe_improveDisini kita akan membuat fungsi untuk membalikkan data yang sudah dinormalisasi. Maksudnya, ketika kita melakukan forecast pada data yang sudah dinormalisasi, kita harus membalikkan data tersebut seperti skala yang sebenarnya.
# revert back function
scotty_recipe_improve_revert <- function(vector, rec, varname) {
# store recipe values
rec_center <- rec$steps[[2]]$means[varname]
rec_scale <- rec$steps[[3]]$sds[varname]
# convert back based on the recipe
results <- (vector * rec_scale + rec_center)^2
# add additional adjustment if necessary
results <- round(results)
# return the results
results
}
scotty_data_improve <- scotty_data_recipe_improve %<>%
gather(src_sub_area, demand, -datetime) %>%
mutate(split = case_when(
datetime %within% interval_train ~ "train",
datetime %within% interval_test ~ "test"
))
scotty_data_improveDisini kita akan melakukan nesting data train dan data test, lalu membuat list fungsi time-series dan permodelan seperti pada tahap sebelumnya. Tidak ada yang spesial atau berbeda dari tahap ini. Tahap ini sama seperti tahap sebelumnya.
scotty_data_improve %<>%
group_by(src_sub_area, split) %>%
nest(data = c(datetime, demand)) %>%
pivot_wider(names_from = split, values_from = data)
ts_function <- list(
ts = function(x) ts(x$demand, frequency = 24), #harian
msts = function(x) msts(x$demand, seasonal.periods = c(24, 24*7)) #mingguan
)
ts_function %<>%
rep(length(unique(scotty$src_sub_area))) %>%
enframe("ts_func_name", "ts_func") %>%
mutate(src_sub_area= sort(rep(unique(scotty$src_sub_area), length(unique(.$ts_func_name)))))
scotty_data_improve <- left_join(scotty_data_improve, ts_function)## Joining, by = "src_sub_area"
## Warning: Column `src_sub_area` joining character vector and factor, coercing
## into character vector
model_list <- list(
stlm = function(x) stlm(x),
tbats = function(x) tbats(x),
HoltWinters = function(x) HoltWinters(x),
autoarima = function(x) auto.arima(x),
arfima = function(x) arfima(x),
ets = function(x) ets(x)
)
model_list %<>%
rep(length(unique(scotty_data_improve$src_sub_area))) %>%
enframe("model_name", "model_func") %>%
mutate(src_sub_area = sort(rep(unique(scotty_data_improve$src_sub_area), length(unique(.$model_name)))))
(scotty_data_improve <- left_join(scotty_data_improve, model_list))## Joining, by = "src_sub_area"
scotty_data_improve <- scotty_data_improve %>%
filter(!(ts_func_name == "msts" & model_name == "autoarima")) %>%
filter(!(ts_func_name == "msts" & model_name == "ets"))
scotty_data_improve %<>%
mutate(
params = map(train, ~list(x = .x)),
data = invoke_map(ts_func, params),
params = map(data, ~list(x = .x)),
fitted = invoke_map(model_func, params))## Warning in HoltWinters(x): optimization difficulties: ERROR:
## ABNORMAL_TERMINATION_IN_LNSRCH
Di tahap ini kita akan memilih model secara otomatis, yaitu yang mempunyai nilai error terkecil.
scotty_improve_best <- scotty_data_improve %>%
mutate(
error = map(fitted, ~ forecast(.x, h = 24 * 7)) %>%
map2_dbl(test, ~ mae_vec(truth = .y$demand, estimate = .x$mean))
) %>%
arrange(src_sub_area, error) %>%
group_by(src_sub_area) %>%
filter(error == min(error)) %>%
ungroup()scotty_data_improve %<>%
mutate(
forecast = map(fitted, ~forecast(.x, h = 24*7)) %>%
map2(test, ~tibble(datetime = .y$datetime, demand = as.vector(.x$mean))),
key = paste(ts_func_name, model_name, sep = "-")) %>%
select(src_sub_area, key, actual = test, forecast) %>%
spread(key, forecast) %>%
gather(key, value, -src_sub_area) %>%
unnest(value) %>%
mutate(demand = scotty_recipe_improve_revert(demand, scotty_recipe_improve, src_sub_area)) %>%
filter(
paste(key, src_sub_area, sep = "-") %in%
paste(scotty_improve_best$ts_func_name,
scotty_improve_best$model_name,
scotty_improve_best$src_sub_area,
sep = "-")
| key == "actual") %>%
mutate(demand = as.numeric(demand),
key = if_else(key == "actual", "actual", "forecast")) %>%
spread(key, demand)## Warning: attributes are not identical across measure variables;
## they will be dropped
scotty_per_sub_improve <- scotty_data_improve %>%
group_by(src_sub_area) %>%
nest() %>%
mutate(tmp = map(data, ~invoke_map_dfc(list(MAE = mae_vec),
truth = .x$actual,
estimate = .x$forecast)))mae_all_sub <- MAE(scotty_data_improve$forecast, scotty_data_improve$actual)
scotty_per_sub_improve[4,1] = "all-sub-area"
scotty_per_sub_improve[4,2] = as.numeric(mae_all_sub)
scotty_per_sub_improve$MAE <- round(scotty_per_sub_improve$MAE, 4)
scotty_per_sub_improveSetelah mempunyai model terbaik, maka tahap selanjutnya adalah melakukan forecast untuk data validation set, yaitu data yang akan digunakan untuk submit capstone ini.
validation_forecast <- scotty_improve_best %>%
select(src_sub_area, train, everything(), -test) %>%
mutate(forecast = map(fitted, ~forecast(.x, h = 24*14)) %>%
map2(train, ~tibble(datetime = timetk::tk_make_future_timeseries(.y$datetime, 24*7*2),
demand = as.vector(.x$mean))))validation_forecast <- validation_forecast %>%
select(src_sub_area, actual = train, forecast) %>%
gather(key, value, -src_sub_area) %>%
unnest(value) %>%
mutate(demand = scotty_recipe_improve_revert(demand, scotty_recipe_improve, src_sub_area))## Warning: attributes are not identical across measure variables;
## they will be dropped
forecast_end <- max(validation_forecast$datetime)
forecast_start <- forecast_end - hours(24*7) + hours(1)
interval_validation <- interval(forecast_start, forecast_end)
submit_validation_forecast <- validation_forecast %>%
mutate(type = case_when(
datetime %within% interval_validation ~ "forecast")) %>%
filter(type == "forecast") %>%
select(-c(key, type))Menyimpan prediksi untuk tanggal 3 Desember sampai dengan 9 Desember 2017.
Untuk melihat hasil forecast.
validation_forecast %>%
ggplot(aes(x = datetime, y=demand, colour = key)) +
geom_line() +
facet_wrap(~ src_sub_area, nrow = 3) +
theme(legend.position = "none")Berikut di bawah ini adalah hasil leaderboard score yang didapat setelah saya submit file hasil prediksi.
Salah satu kriteria model time-series yang bagus adalah tidak adanya Autocorrelation dan Normality Residual
Area sxk97
##
## Box-Ljung test
##
## data: residuals(scotty_improve_best$fitted[[1]])
## X-squared = 1.5942, df = 1, p-value = 0.2067
Area sxk9e
##
## Box-Ljung test
##
## data: residuals(scotty_improve_best$fitted[[2]])
## X-squared = 0.45287, df = 1, p-value = 0.501
Area sxk9s
##
## Box-Ljung test
##
## data: residuals(scotty_improve_best$fitted[[3]])
## X-squared = 2.7548e-06, df = 1, p-value = 0.9987
Untuk Box-Ljung, asumsinya adalah:
H0: no-autocorrelation, p-value > 0.05
H1: autocorrelation, p-value < 0.05
Karena p-value dari semua area adalah > 0.05, maka artinya model kita tidak mengandung autokorelasi
Di bawah ini adalah gradik sebaran normal untuk pengujian asumsi normalitas.
par(mfrow = c(1,3))
sxk97 <- hist(residuals(scotty_improve_best$fitted[[1]]), breaks = 30,
col = "chocolate", border = "red", main = "sxk97", xlab = "Sebaran Normal")
sxk9e <- hist(residuals(scotty_improve_best$fitted[[2]]), breaks = 30,
col = "chocolate", border = "red", main = "sxk9e", xlab = "Sebaran Normal")
sxk9s <- hist(residuals(scotty_improve_best$fitted[[3]]), breaks = 30,
col = "chocolate", border = "red", main = "sxk9s", xlab = "Sebaran Normal")Dari grafik, kita bisa lihat secara visual bahwa data error kita terdistribusi dengan normal.
Pada kasus ini, forecast menggunakan multiple seasonality menghasilkan prediksi yang lebih baik.
Pada ketiga lokasi, forecast menggunakan permodelan TBATS menghasilkan prediksi yang lebih baik.
Dengan melakukan outlier treatment, yaitu memotong data yang ekstrim dengan metode Winsorize serta normalisasi data dengan pengakaran, center dan scale, dapat menghasilkan prediksi yang lebih baik.
Permodelan yang dilakukan, yaitu TBATS dengan tipe data multiple seasonality time series, error dari model menghasilkan error yang memenuhi asumsi.