Jakarta Public Transportation Daily Total Passengers Analysis and Modelling

The article version can be seen in here

Import packages

library(tsibble)

Attaching package: 'tsibble'
The following objects are masked from 'package:base':

    intersect, setdiff, union
library(feasts)
Loading required package: fabletools
library(fable)
library(jsonlite)
library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.2.1     ✔ readr     2.2.0
✔ forcats   1.0.1     ✔ stringr   1.6.0
✔ ggplot2   4.0.3     ✔ tibble    3.3.1
✔ lubridate 1.9.5     ✔ tidyr     1.3.2
✔ purrr     1.2.2     
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter()       masks stats::filter()
✖ purrr::flatten()      masks jsonlite::flatten()
✖ lubridate::interval() masks tsibble::interval()
✖ dplyr::lag()          masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(ggplot2)
library(rvest)

Attaching package: 'rvest'

The following object is masked from 'package:readr':

    guess_encoding
library(imputeTS)
library(scales)

Attaching package: 'scales'

The following object is masked from 'package:purrr':

    discard

The following object is masked from 'package:readr':

    col_factor
library(ggtime)

theme_set(theme_minimal())
sc_y <- scale_y_continuous(labels = label_number(scale_cut = cut_short_scale()))

The API call only returns data from 2024, so downloading the excel file from the website is recommended. The dataset is taken from https://satudata.jakarta.go.id/open-data/detail?kategori=dataset&page_url=jumlah-penumpang-angkutan-umum-yang-terlayani-perhari&data_no=1

during the time of analysis, the latest data is at 2026-04-30.

# API Call
df <- read_html("./data/jumlah-penumpang-angkutan-umum-yang-terlayani-per-hari-komponen-data.xls") %>%
  html_table()

# To csv
write.csv(df, "./data/daily_transport.csv", row.names=FALSE)

# CSV if exist
df <- read.csv("./data/daily_transport.csv")

df %>% head(5)
  periode_data    tanggal jenis_moda jumlah_penumpang_per_hari
1       202402 2024-02-25      KAPAL                      4535
2       202402 2024-02-26      KAPAL                      1830
3       202402 2024-02-27      KAPAL                      1673
4       202402 2024-02-28      KAPAL                      1479
5       202402 2024-02-29      KAPAL                      1477

Noted that some data in jumlah_penumpang_per_hari contains , as a delimiter and messes up the integer conversion.

df %>% 
  filter(tanggal == "2024-11-11" & jenis_moda == "TRANSJAKARTA")
  periode_data    tanggal   jenis_moda jumlah_penumpang_per_hari
1       202411 2024-11-11 TRANSJAKARTA                   125,239
df <- df %>% 
  mutate(jumlah_penumpang_per_hari = gsub(",", "", jumlah_penumpang_per_hari)) %>% 
  select(-"periode_data") %>% 
  mutate(tanggal = ymd(tanggal),
         jumlah_penumpang = as.integer(jumlah_penumpang_per_hari)) %>% 
  select(-"jumlah_penumpang_per_hari") %>% 
  as_tsibble(index=tanggal, key=jenis_moda)

EDA

df %>% 
  index_by(Year = ~year(.)) %>% 
  group_by(jenis_moda, Year) %>% 
  summarise(penumpang_pertahun = sum(jumlah_penumpang)) %>% 
  arrange(jenis_moda, Year)
# A tsibble: 22 x 3 [1Y]
# Key:       jenis_moda [8]
   jenis_moda            Year penumpang_pertahun
   <chr>                <dbl>              <int>
 1 BUS SEKOLAH           2024            8808034
 2 BUS SEKOLAH           2025            8602565
 3 BUS SEKOLAH           2026            2905048
 4 KAPAL                 2024            1187696
 5 KAPAL                 2025            1159432
 6 KAPAL                 2026             360031
 7 KCI COMMUTER BANDARA  2024            2225159
 8 KCI COMMUTER BANDARA  2025            2489612
 9 KCI COMMUTER BANDARA  2026             822535
10 KRL                   2024          325243803
# ℹ 12 more rows

Note that 2026 does not have a full year data yet, which is why the value drops

ggplot(df, aes(tanggal, jumlah_penumpang)) +
  geom_line(linewidth = 0.3) +
  facet_wrap(~ jenis_moda, scales = "free_y", ncol = 2)

Seems that the definition of MIKROTRANS is only defined early 2026 while previously merged into TRANSJAKARTA.

df_merged <- df %>%
    as_tibble() %>% 
  mutate(jenis_moda = if_else(jenis_moda == "MIKROTRANS", "TRANSJAKARTA", jenis_moda)) %>%
  group_by(tanggal, jenis_moda) %>%
  summarise(jumlah_penumpang = sum(jumlah_penumpang, na.rm = TRUE), .groups = "drop") %>% 
  as_tsibble(index=tanggal, key=jenis_moda)
ggplot(df_merged, aes(tanggal, jumlah_penumpang)) +
  geom_line(linewidth = 0.3) +
  facet_wrap(~ jenis_moda, scales = "free_y", ncol = 2)

Check for gaps

df_merged %>% 
  scan_gaps()
# A tsibble: 0 x 2 [?]
# Key:       jenis_moda [0]
# ℹ 2 variables: jenis_moda <chr>, tanggal <date>
df_merged %>% 
  filter(jumlah_penumpang == 0) %>% 
  count(jenis_moda)
# A tibble: 3 × 2
  jenis_moda               n
  <chr>                <int>
1 BUS SEKOLAH             81
2 KCI COMMUTER BANDARA     2
3 KRL                      2
df_merged %>% 
  filter(jumlah_penumpang == 0 & jenis_moda != "BUS SEKOLAH")
# A tsibble: 4 x 3 [1D]
# Key:       jenis_moda [2]
  tanggal    jenis_moda           jumlah_penumpang
  <date>     <chr>                           <int>
1 2024-12-16 KCI COMMUTER BANDARA                0
2 2024-12-17 KCI COMMUTER BANDARA                0
3 2024-12-16 KRL                                 0
4 2024-12-17 KRL                                 0

Seasonality

School bus generally operates from Monday to Friday. So we expect to see said weekly pattern in this plot.

df_merged %>% 
  filter(jenis_moda == "BUS SEKOLAH") %>% 
  gg_season(period="week", y=jumlah_penumpang)

There are a lot of weeks in here so it looks really cluttered in the operational interval. But, it is clear that the value dips in Saturday and Sunday.

Hence, we will use a boxplot in order to smoothen things and we can see some general pattern for each transportation methods.

df_merged %>%
  mutate(day_of_week = wday(tanggal, label = TRUE, week_start = 1)) %>%
  ggplot(aes(day_of_week, jumlah_penumpang)) +
  geom_boxplot(outlier.size = 0.2) +
  facet_wrap(~ jenis_moda, scales = "free_y", ncol = 2)

Some general weekdays and weekends pattern seen all categories except for LRT.

df_merged %>%
  filter(year(tanggal) == 2024) %>% 
  mutate(month = month(tanggal, label=TRUE)) %>%
  ggplot(aes(month, jumlah_penumpang)) +
  geom_boxplot(outlier.size = 0.2) +
  facet_wrap(~ jenis_moda, scales = "free_y", ncol = 2) +
  sc_y

There is no pattern here, except that there are times where BUS SEKOLAH are zero’d because of school holiday. KAPAL shows a lot of outliers, presumably because of Eid. KCI COMMUTER BANDARA have a small amount of passengers and a lot of outliers so it might be hard to model. Finally, LRT as mentioned previousy, does not show weekly pattern. Hence, for modelling, we will focus on KRL, MRT, and TRANSJAKARTA.

df_weekly <- df_merged %>%
  as_tibble() %>% 
  mutate(year = year(tanggal),
         woy  = week(tanggal)) %>%          
  group_by(jenis_moda, year, woy) %>%
  summarise(mean_daily = mean(jumlah_penumpang, na.rm = TRUE),
            n = sum(!is.na(jumlah_penumpang)), 
            .groups = "drop") %>% 
  filter(n>=4) 

ggplot(df_weekly, aes(woy, mean_daily, colour = factor(year))) +
  geom_line(linewidth = 0.4) +
  facet_wrap(~ jenis_moda, scales = "free_y", ncol = 2) +
  labs(colour = "year", x = "week of year")

We can see that a lot of transport shows a dip during Eid while KAPAL and KCI COMMUTER BANDARA have a spike during that time, which makes sense because people tend to travel far during that time. Recall that this is transport data for Jakarta, meaning that a lot of people that stayed in Jakarta, are not actually from there.

Very high spike for end of year for both KRL and KCI COMMUTER BANDARA. This does not look normal, especially since the year before that, there is a slight dip. This warrants further investigation.

Anomaly on KRL and KCI

https://www.bps.go.id/id/statistics-table/2/NzIjMg==/jumlah-penumpang-kereta-api.html Total for Jabodetabek in December is actually 31640000 according to this website. So, let us check the dataset against these statements. While we are at it, we will also check for every month as well.

bps_data_krl = c(26848,24617,26012,25543,27057,26739,29241,28209,27608,29933,27522,28825, 27522,27204,26974,27552,28561,28205,31401,28947,28741,31777,30373,31640)

bps_compare <- df_merged %>% 
  filter(jenis_moda == "KRL" & year(tanggal) < 2026) %>% 
  as_tibble() %>% 
  mutate(year_month = yearmonth(tanggal)) %>% 
  select(year_month, jumlah_penumpang) %>% 
  group_by(year_month) %>% 
  summarise(satudata = sum(jumlah_penumpang)) %>% 
  mutate(bps = bps_data_krl * 1000) %>% 
  pivot_longer(cols = c(satudata, bps), names_to="type") %>% 
  as_tsibble(index=year_month, key=type) 

bps_compare %>% 
  autoplot(.vars=value) + sc_y

bps_compare %>% 
  filter(month(year_month) == 12 & year(year_month) != 2026) %>% 
  pivot_wider(name=type) %>% 
  mutate(ratio = satudata/(bps))
# A tsibble: 2 x 4 [12M]
  year_month      bps satudata ratio
       <mth>    <dbl>    <dbl> <dbl>
1   2024 Dec 28825000 25710234 0.892
2   2025 Dec 31640000 48173042 1.52 

The plot overlaps most of the time except at December. April also have some slight deviation.

For April 2024, it does not seem that there was an anomaly in the dataset. We will leave it be and move on to December of both years.

df_merged %>% 
  filter(year(tanggal) == 2024 & month(tanggal) == 4 & jenis_moda == "KRL") %>% 
  autoplot(.vars=jumlah_penumpang) + sc_y

For December 2024 deviation, the dataset actually understates compared to BPS, which is why we have the dip earlier. Recall that when checking for 0 total passengers, KRL and KCI does have two entries that are zero in December 2024. Imputation will be done since a gap in the middle of dataset is really troublesome.

df_clean <- df_merged %>%
  mutate(jumlah_penumpang = if_else(
    jenis_moda %in% c("KRL", "KCI COMMUTER BANDARA") & jumlah_penumpang == 0,
    NA_integer_, jumlah_penumpang)) %>% 
  group_by_key() %>% 
  mutate(jumlah_penumpang = na_seasplit(jumlah_penumpang, find_frequency= TRUE)) %>% 
  ungroup()

Re check with BPS

bps_compare <- df_clean %>% 
  filter(jenis_moda == "KRL" & year(tanggal) < 2026) %>% 
  as_tibble() %>% 
  mutate(year_month = yearmonth(tanggal)) %>% 
  select(year_month, jumlah_penumpang) %>% 
  group_by(year_month) %>% 
  summarise(satudata = sum(jumlah_penumpang)) %>% 
  mutate(bps = bps_data_krl * 1000) %>% 
  pivot_longer(cols = c(satudata, bps), names_to="type") %>% 
  as_tsibble(index=year_month, key=type) 

bps_compare %>% 
  autoplot(.vars=value)+ sc_y

df_clean %>% 
  filter(jenis_moda == "KRL" &
           between(tanggal, ymd("2025-12-01"), ymd("2026-01-31"))) %>% 
  autoplot(.vars=jumlah_penumpang)

So, between 13 December and 31 December, is the source of the sharp increase.

https://money.kompas.com/read/2025/12/26/163800926/penumpang-krl-jabodetabek-724.536-orang-saat-libur-natal-2025

Two statements from this news 1. “18 sampai 25 Desember 2025 sebanyak 7.839.814” Passengers from 18 - 25 December 2025 totals 7839814. 2. “PT KAI Commuter Indonesia (KCI) mencatat jumlah penumpang Commuter Line atau KRL Jabodetabek mencapai 724.536 orang pada libur Natal 2025, Kamis (25/12/2025)” Total passengers in 25 December 2025 is 724536

df_clean <- df_clean %>%
  as_tibble() %>% 
  mutate(
    jumlah_penumpang = if_else(jenis_moda == "KRL" & between(tanggal, as.Date("2025-12-13"), as.Date("2025-12-31")), jumlah_penumpang/2, jumlah_penumpang)
    ) %>% 
  as_tsibble(index=tanggal, key=jenis_moda)
df_clean %>% 
  filter(jenis_moda == "KRL" &
           between(tanggal, ymd("2025-12-01"), ymd("2026-01-31"))) %>% 
  autoplot(.vars=jumlah_penumpang)

df_clean %>% 
  filter(jenis_moda == "KRL", tanggal == as.Date("2025-12-25"))
# A tsibble: 1 x 3 [1D]
# Key:       jenis_moda [1]
  tanggal    jenis_moda jumlah_penumpang
  <date>     <chr>                 <dbl>
1 2025-12-25 KRL                  722105

Around 2000 passengers short in 25 December compared to the news article.

df_clean %>% 
  filter(jenis_moda == "KRL", between(tanggal, as.Date("2025-12-18"), as.Date("2025-12-25"))) %>% 
  summarise(total = sum(jumlah_penumpang))
# A tsibble: 8 x 2 [1D]
  tanggal      total
  <date>       <dbl>
1 2025-12-18 1036708
2 2025-12-19 1010271
3 2025-12-20  782050
4 2025-12-21  668058
5 2025-12-22 1096648
6 2025-12-23 1050265
7 2025-12-24 1046964
8 2025-12-25  722105

And around 400.000 passengers short in 18-25 December interval. Which is better than previously. Let us recheck with BPS.

bps_compare <- df_clean %>% 
  filter(jenis_moda == "KRL" & year(tanggal) < 2026) %>% 
  as_tibble() %>% 
  mutate(year_month = yearmonth(tanggal)) %>% 
  select(year_month, jumlah_penumpang) %>% 
  group_by(year_month) %>% 
  summarise(satudata = sum(jumlah_penumpang)) %>% 
  mutate(bps = bps_data_krl * 1000) %>% 
  pivot_longer(cols = c(satudata, bps), names_to="type") %>% 
  as_tsibble(index=year_month, key=type) 

bps_compare %>% 
  autoplot(.vars=value) + sc_y

Not a perfect overlap, but it is the best I can do without imposing too much assumption.

ggplot(df_clean, aes(tanggal, jumlah_penumpang)) +
  geom_line(linewidth = 0.3) +
  facet_wrap(~ jenis_moda, scales = "free_y", ncol = 2)

Decomposition

Let us perform a time series decomposition, to see the seasonality and trend clearly for the three chosen methods of transport that we want to model. But first, let us mark Eid dates.

eid_dates <- list(
  interval(ymd("2024-04-05"), ymd("2024-04-15")),
  interval(ymd("2025-03-26"), ymd("2025-04-07")),
  interval(ymd("2026-03-15"), ymd("2026-03-27"))
)

df_clean <- df_clean %>% 
  mutate(
    is_eid = as.integer(tanggal %within% eid_dates)
  )

KRL

df_clean %>% 
  filter(jenis_moda == "KRL") %>% 
  model(
    STL(jumlah_penumpang ~ trend() + season(period=7))
  ) %>% 
  components() %>% 
  autoplot() + sc_y

There is a very sharp dip at 2025-01. Recall that there was an imputation at 2024-12 but after checking with BPS data, it is still understated. It seems that we still need to check the numbers around that area again.

df_clean %>% 
  filter(tanggal >= "2024-12-28", tanggal <= "2025-01-07", jenis_moda =="KRL") %>%
  select(tanggal, jumlah_penumpang) %>%
  autoplot(.vars=jumlah_penumpang)

Seems like we missed an anomaly, December 31st is so low compared to the others. It doesn’t make sense, since KRL operational hours is extended during new year’s eve. Since we don’t have any validation, we will set the value to NA and impute it.

df_krl <-
  df_clean %>% 
  filter(jenis_moda == "KRL") %>% 
  mutate(jumlah_penumpang = if_else(tanggal == as.Date("2024-12-31"), NA_real_, jumlah_penumpang)) %>% 
  mutate(jumlah_penumpang = na_seasplit(jumlah_penumpang, find_frequency = TRUE)) %>%
  as_tsibble()

df_krl %>% 
  filter(tanggal >= "2024-12-28", tanggal <= "2025-01-07") %>%
  select(tanggal, jumlah_penumpang) %>%
  autoplot(.vars=jumlah_penumpang)

Now let us check the dip somewhere around August - September.

df_krl %>% 
  filter(between(tanggal, as.Date("2025-08-25"), as.Date("2025-09-10")))
# A tsibble: 17 x 4 [1D]
# Key:       jenis_moda [1]
   tanggal    jenis_moda jumlah_penumpang is_eid
   <date>     <chr>                 <dbl>  <int>
 1 2025-08-25 KRL                 1061996      0
 2 2025-08-26 KRL                 1029099      0
 3 2025-08-27 KRL                 1039279      0
 4 2025-08-28 KRL                  930153      0
 5 2025-08-29 KRL                  948211      0
 6 2025-08-30 KRL                  637907      0
 7 2025-08-31 KRL                  501099      0
 8 2025-09-01 KRL                  666885      0
 9 2025-09-02 KRL                  813466      0
10 2025-09-03 KRL                  944827      0
11 2025-09-04 KRL                 1028791      0
12 2025-09-05 KRL                  660157      0
13 2025-09-06 KRL                  792973      0
14 2025-09-07 KRL                  735672      0
15 2025-09-08 KRL                 1094131      0
16 2025-09-09 KRL                 1053373      0
17 2025-09-10 KRL                 1072070      0

https://voi.id/en/news/509442 Riot https://jakartaglobe.id/news/jakarta-riot-disrupts-roads-rail-and-transjakarta-operations

This is a one-off and honestly, it is quite difficult to handle since we cannot predict these demonstrations reliably. Hence, this interval will be left as is, and noted that it might ruin the model fitting.

df_krl %>% 
  model(
    STL(jumlah_penumpang ~ trend() + season(period=7))
  ) %>% 
  components() %>% 
  autoplot() + sc_y

We can see that the trend still captures some sharp dips that is probably attributed to Eid. This decomposition cannot adjust for that since we stated that we only have weekly seasonality.

We see that there is no structure left in the remainder, meaning we captured most if not all of the seasonality pattern.

TRANSJAKARTA

df_tj <-
  df_clean %>% 
  filter(jenis_moda == "TRANSJAKARTA") %>% 
  as_tsibble()
df_tj %>% 
  model(
    STL(jumlah_penumpang ~ trend() + season(period=7))
  ) %>% 
  components() %>% 
  autoplot() + sc_y

Similar to KRL, the trend absorbed some events that happened throughout the timeline. That includes Eid, and the Riot. There is a sharp dip in the end of 2024 though, so let us check if it needs to be handled.

df_tj %>% 
  filter(between(tanggal, as.Date("2024-11-10"), as.Date("2024-12-01"))) 
# A tsibble: 22 x 4 [1D]
# Key:       jenis_moda [1]
   tanggal    jenis_moda   jumlah_penumpang is_eid
   <date>     <chr>                   <dbl>  <int>
 1 2024-11-10 TRANSJAKARTA           760399      0
 2 2024-11-11 TRANSJAKARTA           125239      0
 3 2024-11-12 TRANSJAKARTA          1285172      0
 4 2024-11-13 TRANSJAKARTA          1302317      0
 5 2024-11-14 TRANSJAKARTA          1297904      0
 6 2024-11-15 TRANSJAKARTA          1281162      0
 7 2024-11-16 TRANSJAKARTA           820681      0
 8 2024-11-17 TRANSJAKARTA           733035      0
 9 2024-11-18 TRANSJAKARTA          1279311      0
10 2024-11-19 TRANSJAKARTA          1288450      0
# ℹ 12 more rows

There is a sharp dip in 2024-11-11. I have no idea what could caused it, but I see that there is a route change that happens in that time.

https://www.instagram.com/p/DCG1_82y070/

But, it seems that it is way too low of a decrease for it to be caused by two route changes. Besides, it’s not like the transportation itself stopped operating, so one might think that this is some kind of system error.

Then, there is another dip in 2024-11-27, which might be caused by regional elections and TRANSJAKARTA only operates from 09:00 AM instead of 05:00 AM.

The first dip might warrant some intervention while the second dip should be left alone and noted.

Let’s impute the first dip.

df_tj <- df_tj %>%
  mutate(jumlah_penumpang = if_else(tanggal == "2024-11-11",
    NA_real_, jumlah_penumpang)) %>%
  group_by_key() %>%
  mutate(jumlah_penumpang = na_seasplit(jumlah_penumpang, find_frequency = TRUE)) %>%
  ungroup()

Check again,

df_tj %>% 
  filter(between(tanggal, as.Date("2024-11-10"), as.Date("2024-12-01"))) 
# A tsibble: 22 x 4 [1D]
# Key:       jenis_moda [1]
   tanggal    jenis_moda   jumlah_penumpang is_eid
   <date>     <chr>                   <dbl>  <int>
 1 2024-11-10 TRANSJAKARTA           760399      0
 2 2024-11-11 TRANSJAKARTA          1276763      0
 3 2024-11-12 TRANSJAKARTA          1285172      0
 4 2024-11-13 TRANSJAKARTA          1302317      0
 5 2024-11-14 TRANSJAKARTA          1297904      0
 6 2024-11-15 TRANSJAKARTA          1281162      0
 7 2024-11-16 TRANSJAKARTA           820681      0
 8 2024-11-17 TRANSJAKARTA           733035      0
 9 2024-11-18 TRANSJAKARTA          1279311      0
10 2024-11-19 TRANSJAKARTA          1288450      0
# ℹ 12 more rows

It is imputed with 1.276M, seems reasonable since it is similar to all other Mondays that are close.

df_tj %>% 
  model(
    STL(jumlah_penumpang ~ trend() + season(period=7))
  ) %>% 
  components() %>% 
  autoplot() + sc_y

MRT

df_mrt <-
  df_clean %>% 
  filter(jenis_moda == "MRT") %>% 
  as_tsibble()
df_mrt %>% 
  model(
    STL(jumlah_penumpang ~ trend() + season(period=7))
  ) %>% 
  components() %>% 
  autoplot() + sc_y

MRT looks really stable. Again, the four previously identified dips appeared. There is nothing to change or to check so we left it as is.

Final Check

There are a lot of unexpected anomalies that required imputations that refers to other dataset. Here is the list of what was imputed

  1. jumlah_penumpang that have 0 for KRL and KCI COMMUTER BANDARA (2024-12-16, and 2024-12-17) are imputed using na_seasplit()
  2. jumlah_penumpang at 2024-12-31 for KRL is replaced with NA and imputed using na_seasplit().
  3. jumlah_penumpang at 2025-12-13 - 2025-12-31 is halved for KRL
  4. jumlah_penumpang at 2024-11-11 for TRANSJAKARTA is low for unknown reason. The valued was replaced with NA. and and imputation method was implemented using na_seasplit()

First, we want to see whether the zero values have been imputed properly for both KRL and KCI COMMUTER BANDARA. We can check the first three imputations by just comparing with the BPS data again.

For KRL

bps_compare <- df_krl %>% 
  filter(year(tanggal) < 2026) %>% 
  as_tibble() %>% 
  mutate(year_month = yearmonth(tanggal)) %>% 
  select(year_month, jumlah_penumpang) %>% 
  group_by(year_month) %>% 
  summarise(satudata = sum(jumlah_penumpang)) %>% 
  mutate(bps = bps_data_krl * 1000) %>% 
  pivot_longer(cols = c(satudata, bps), names_to="type") %>% 
  as_tsibble(index=year_month, key=type) 

bps_compare %>% 
  autoplot(.vars=value) + sc_y

For KCI COMMUTER BANDARA

bps_data_kci = c(618,603,578,704,650,655,711,660,698,675,651,775, 765,709,662,838,764,773,822,786,756,822,777,855)
df_clean %>% 
  filter(jenis_moda == "KCI COMMUTER BANDARA", year(tanggal) < 2026) %>% 
  as_tibble() %>% 
  mutate(year_month = yearmonth(tanggal)) %>% 
  select(year_month, jumlah_penumpang) %>% 
  group_by(year_month) %>% 
  summarise(satudata = sum(jumlah_penumpang)) %>% 
  mutate(bps = bps_data_kci * 1000) %>% 
  pivot_longer(cols = c(satudata, bps), names_to="type") %>% 
  as_tsibble(index=year_month, key=type) %>% 
  autoplot(.vars=value) + sc_y

Well, I suppose there is a fundamental difference between how Satudata and BPS defined it. So, we can’t really check.

We mentioned that there is a regional election at 2024-11-27 and it does affect TRANSJAKARTA total passengers due to reduced operational hours and holiday. Does it affect other transport as well?

df_clean %>% 
  filter(between(tanggal, as.Date("2024-11-20"), as.Date("2024-11-30"))) %>% 
ggplot(aes(tanggal, jumlah_penumpang)) +
  geom_line(linewidth = 0.3) +
  facet_wrap(~ jenis_moda, scales = "free_y", ncol = 2) +
  sc_y

Since it is a public holiday, it will affect BUS SEKOLAH. Also, it does affect KRL, MRT, KCI COMMUTER BANDARA, and maybe LRT.

df_clean %>% 
  filter(jenis_moda %in% c("KRL", "TRANSJAKARTA", "MRT")) %>% 
  ACF(jumlah_penumpang) %>% 
  autoplot()

For modelling purposes, we confirmed that there is a seasonal component with 7 days period (weekly) as shown for all three jenis_moda.

Summary

We have found some interesting findings throughout the analysis. We noted some things and adjusted the data for others. Here are some findings that led the decision to adjust the data.

First, we saw that MIKROTRANS starts at the same time as when there was a chunk of data missing from TRANSJAKARTA. Hence, we decided to just merge them together.

Then, we checked for 0 values and found that BUS SEKOLAH has 81 entries that are 0 which we left as is, and both KRL and KCI COMMUTER BANDARA has 2 entries that are 0 at the same date, which we impute.

We found a sharp spike of total passengers for both KRL and KCI COMMUTER BANDARA at 13 - 31 December 2025. We validated this finding for KRL through a BPS dataset and some news articles. We decided to divide the total passengers at that time period by half since it the value looks doubled. Nothing was done for KCI COMMUTER BANDARA.

On the contrary, in 31st December of 2024, there is a sharp dip. We found no reason why this is the case, but we decided to multiply the total passengers by 2 and the result looks fine after.

There are events that happened throughout the year. Those events are considered to be a part of the model if it happens at a predictable interval, or kept as is if it is a random occurence. One event that happens every year is Eid. For that event, every transportation method except for KAPAL and KCI COMMUTER BANDARA took a dip.

Another notable event is the riot that happened from 25 August 2025 until 9 September 2025. The dip does not happened at that exact interval but somewhere within. That is caused by some stations stopped operating completely.

Lastly, there is a public holiday for a regional election in Jakarta at 2024 November 27. Every transportation method took a dip for that day.

One finding about seasonality is that almost all of the transportation method have a weekday and weekend pattern. BUS SEKOLAH stops operating at weekends, KAPAL have an increase of passengers during weekends, and the rest have a decrease of passengers during weekends except for LRT which does not seem to show any weekly seasonality.

Looking at yearly seasonality however, there are barely any visible pattern. We can only see an increase in outliers for KAPAL and KCI COMMUTER BANDARA during Eid. However, the total passengers is quite low to begin with. There are times where BUS SEKOLAH are zero’d because of school holiday. Finally, LRT as mentioned previousy, does not show weekly pattern. Hence, for modelling, we will focus on KRL, MRT, and TRANSJAKARTA.

Models

KRL

First Pass

Before we commit to cross validations, let us take a peek and maybe find out some hyperparameters if applicable.

train_krl <- df_krl %>% 
  filter(tanggal <= max(tanggal) - 56)

fits_krl <- train_krl %>% 
  model(
    snaive = SNAIVE(jumlah_penumpang ~ lag("week")),
    ets    = ETS(jumlah_penumpang),
    arima  = ARIMA(jumlah_penumpang),
    arima_eid = ARIMA(jumlah_penumpang ~ is_eid)
  )
fits_krl %>% 
  select(ets) %>% 
  report()
Series: jumlah_penumpang 
Model: ETS(A,N,A) 
  Smoothing parameters:
    alpha = 0.2693505 
    gamma = 0.03483225 

  Initial states:
     l[0]      s[0]     s[-1]    s[-2]    s[-3]   s[-4]    s[-5]    s[-6]
 879222.5 -174435.3 -110178.5 46540.49 63409.83 66769.2 67108.89 40785.43

  sigma^2:  8724059539

     AIC     AICc      BIC 
23517.26 23517.54 23564.05 

The smoothing parameters that were picked explains the decay of level and seasonality components. Alpha explains the decay of the level component, meaning the value from previous day are scaled by 0.269 and carried by the current value. Meanwhile, the gamma explains the seasonality component. The value is so small, but it explains how the value from previous season (last week) affects the current value.

The model picks additive error, no trend, and additive seasonality. The reason additive was chosen for error and seasonality term is most likely because these two does not grow as time goes. Meaning, the fluctuations that came from the weekly seasonality does not grow bigger as time goes on, and that also applies for the error term.

fits_krl %>% 
  select(ets) %>% 
  components() %>% 
  autoplot()
Warning: Removed 20 rows containing missing values or values outside the scale range
(`geom_line()`).

fits_krl %>% 
  select(arima) %>% 
  report()
Series: jumlah_penumpang 
Model: ARIMA(2,0,0)(1,1,2)[7] 

Coefficients:
         ar1     ar2     sar1     sma1     sma2
      0.3778  0.0817  -0.0898  -0.7665  -0.1531
s.e.  0.0357  0.0357   0.4149   0.4111   0.3778

sigma^2 estimated as 8.067e+09:  log likelihood=-10109.89
AIC=20231.77   AICc=20231.88   BIC=20259.79

The model decides an SARIMA model with parameters - Autoregressive of order 2 - Seasonal autoregressive of order 1 at lag 7 - Seasonal differencing of order 1 at lag 7 - Seasonal moving average of order 2 at lag 7

Autoregressive of order 2 suggest that the non-seasonal component of the model depends on the past two values.

The seasonal differencing of order 1 basically strips down the seasonality pattern, and the order 1 says that once the changes between the current and last season (in this case, same day of last week), the time series becomes stationary. In simpler terms, the seasonality is constant.

Seasonal autoregressive of order 1 says that the current value depends on the last season value (last week’s value), and the seasonal moving average of order 2 suggest that the current value is affected by the last two season shocks (noises).

However, it is important to note that some coefficients include 0 because of the standard error. Both the first order of the seasonal autoregressive and 2nd order of the seasonal moving average have a larger standard error than their value. Meaning, these coefficients might not be significant, but is still included because it still did improve AICc, which is the selection criterion used.

fits_krl %>% 
  select(arima_eid) %>% 
  report()
Series: jumlah_penumpang 
Model: LM w/ ARIMA(2,0,0)(2,1,2)[7] errors 

Coefficients:
         ar1     ar2    sar1     sar2     sma1     sma2      is_eid
      0.3403  0.0408  0.0624  -0.0137  -0.9180  -0.0101  -157269.25
s.e.  0.0358  0.0359  0.9330   0.0785   0.9325   0.8679    27527.31

sigma^2 estimated as 7.801e+09:  log likelihood=-10095.59
AIC=20207.19   AICc=20207.37   BIC=20244.54

Adding a regressor term for Eid did change the model parameter slightly. Instead of seasonal autoregressive of order 1, it is now of order 2. Meaning, it argues that the model adjusted its dependence from just the last season to the last two season. However, some of these components have a large standard errors that exceeds their coefficients. In this case, - The second order of autoregressive - The seasonal autoregressive components - The seasonal moving average components

Meanwhile, is_eid component says that during eid, the passengers on average decreases by 157K with 27K standard error. So, having is_eid regressor term, weakens more components and may allow us reduce the components for a simpler model. This will be investigated further.

fits_krl <- train_krl %>% 
  model(
    snaive = SNAIVE(jumlah_penumpang ~ lag("week")),
    ets    = ETS(jumlah_penumpang),
    arima  = ARIMA(jumlah_penumpang),
    arima_eid = ARIMA(jumlah_penumpang ~ is_eid),
    arima_eid_adj = ARIMA(jumlah_penumpang ~ is_eid + pdq(1,0,0) + PDQ(0,1,1, period=7) )
  )
fits_krl %>% 
  select(arima_eid_adj) %>% 
  report()
Series: jumlah_penumpang 
Model: LM w/ ARIMA(1,0,0)(0,1,1)[7] errors 

Coefficients:
         ar1     sma1      is_eid
      0.3585  -0.9179  -157452.52
s.e.  0.0336   0.0165    26235.84

sigma^2 estimated as 7.814e+09:  log likelihood=-10098.17
AIC=20204.34   AICc=20204.39   BIC=20223.02

Compared to the previous model with 20207.37 AICc, we now have 20204.39. This is caused by the code actually used a greedy algorithm to find the best model. So, not only, it has a smaller criterion, it is also a simpler model. We will use the seasonal naive as a baseline, ETS as the next best model, and SARIMA(1,0,0)(0,1,1) as the best model and do cross validation.

Model Validation

Let us peek at how the model performs through its residuals. It is not necessary, but it is nice to see whether the model captures the structure of the data or not.

fits_krl %>% 
  select(c(snaive)) %>% 
  gg_tsresiduals()

The first plot, the innovation residuals seems pretty cluttered. But, recall that we have identified events that affected the total passengers greatly. We see that early 2024, a lot of fluctuations in the residuals, which coincides with 2024 Eid timing. Then, the fluctuations starts again early 2025, and spikes after 2025-07 point, where the riot happened. Then, 2026 Eid also shows some fluctuations. It is expected that the model could not capture these events, and hence the residuals are affected during those events.

The second plot to look at is the ACF. It explains the autocorrelation for the residuals. We still have a strong autocorrelation at lag 1, which means that the residuals at current time is still correlated to the previous time. In other words, the model did not fully capture the relationship between current time and previous time (yesterday). The same can be said for lag 7, which is last week.

Also, we would like for 95% of the autocorrelation to be below the dashed blue line because that would imply that the residuals are within the 95% confidence band of white noise. In this case, we see a lot of lines crossed the blue line, and can safely conclude that the residuals are not white noise.

Finally, the residuals density plot. We can see that it is bell shaped, but it has long tails, so it doesn’t really fit the normal distribution. Let us continue with the other models briefly.

fits_krl %>% 
  select(c(ets)) %>% 
  gg_tsresiduals()

Again, fluctuations can be seen during event timings, although after 2024 events, most of the residuals went into the negative, meaning that the prediction overstates during that time. Expected since the model did not know any of these events. The ACF plot still have a strong autocorrelation for lag 1, but have no strong weekly correlation since it is captured by the seasonality component of the model. Lastly, the density plot shows that it has a long left tail, because the model overestimates during events.

fits_krl %>% 
  select(c(arima_eid_adj)) %>% 
  gg_tsresiduals()

The innovation residuals looks pretty similar with ETS model despite having a regressor term for Eid. The only visible difference is that for earlier time, during 2024, this model did slightly better than ETS. The ACF plot have spikes at random lag, which we could not attribute to anything. Not to mention, there are only 2 lines out of 28 that exceeds the blue line. It is still not enough to be stated as white noise considering this plot. Next, is the density plot, which looks really similar to the ETS one. The left tail is a little bit more clustered and the center is a bit more spread out compared to ETS.

fits_krl %>% 
  augment() %>% 
  filter(.model == "arima_eid_adj") %>% 
  features(.innov, ljung_box, lag = 14, dof = 2)
# A tibble: 1 × 4
  jenis_moda .model        lb_stat lb_pvalue
  <chr>      <chr>           <dbl>     <dbl>
1 KRL        arima_eid_adj    12.2     0.427
fits_krl %>% 
  augment() %>% 
  filter(.model %in% c("ets", "snaive")) %>% 
  features(.innov, ljung_box, lag = 14, dof = 0)
# A tibble: 2 × 4
  jenis_moda .model lb_stat   lb_pvalue
  <chr>      <chr>    <dbl>       <dbl>
1 KRL        ets       59.4 0.000000149
2 KRL        snaive   382.  0          

We can also test whether the residuals are white noise using ljung-box test. The null hypothesis is the residuals are white noise, meaning in this case, if the p-value is less than 0.05, then with 95% confidence we can reject the null. Both ETS and SNAIVE have p-value less than 0.05, while SARIMA hovers around 0.42. Therefore, according to ljung-box test, we can’t reject that the residuals from SARIMA model are white noise.

Decided the lag using the general rule of \(h = \text{min}(2p, n/5)\) where \(p\) is the period of seasonality.

Cross Validation

krl_folds <- df_krl %>% 
  select(-jenis_moda) %>% 
  stretch_tsibble(.init = 500, .step = 7)

cv_fits_krl <- krl_folds %>% 
  model(
    snaive      = SNAIVE(jumlah_penumpang ~ lag("week")),
    ets         = ETS(jumlah_penumpang),
    arima_eid_adj = ARIMA(jumlah_penumpang ~ 0 + is_eid + pdq(1,0,0) + PDQ(0,1,1, period=7))
  ) 

future_krl <- new_data(krl_folds, 7) %>% 
  mutate(is_eid = as.integer(tanggal %within% eid_dates))
cv_fits_krl %>% 
  forecast(new_data = future_krl) %>% 
  accuracy(df_krl) %>% 
  select(.model, MASE, RMSSE) %>% 
  arrange(MASE)
# A tibble: 3 × 3
  .model         MASE RMSSE
  <chr>         <dbl> <dbl>
1 ets           0.802 0.747
2 arima_eid_adj 0.829 0.722
3 snaive        1.02  1.01 

We will focus on two metrics, the Mean Absolute Scaled Error (MASE) and Root Mean Squared Scaled Error (RMSSE). We use the recommended scaled error metrics where we aim for lower than 1 value since having scaled errors equal to one means that it would be the same as a naive baseline (same as last value).

Both ETS and SARIMA model have less than 1 MASE, and performs better than the seasonal naive forecast. We see that although ETS have the best MASE, the SARIMA have lower RMSSE. SARIMA having lower RMSSE implies that SARIMA handles spikes in total passengers better than ETS, but having higher MASE means that the model performs worse in general.

cv_fits_krl %>% 
  forecast(new_data = future_krl) %>% 
  accuracy(df_krl, by = c(".model", ".id")) %>% 
  left_join(krl_folds %>% as_tibble() %>% group_by(.id) %>% 
              summarise(origin = max(tanggal)), by = ".id") %>% 
  ggplot(aes(origin, MAE, colour = .model)) +
  geom_line() + sc_y

We can see the spike in errors are correlated to the events that we mentioned earlier. Eid, and riot caused some error spikes, but there are other spikes that we did not classify. SARIMA model seem to handle these jumps very well, except at July 2025, but in general when there is no spikes, the ETS model seems to perform better. This seems to be the trade-off that we have right now.

cv_fits_krl %>% 
  forecast(new_data = future_krl) %>% 
  as_tibble() %>% 
  ggplot(aes(tanggal, .mean, colour = .model, group = interaction(.model))) +
  geom_line(linewidth = 0.3) +
  geom_line(data = df_krl, aes(tanggal, jumlah_penumpang), 
            colour = "black", linewidth = 0.4, inherit.aes = FALSE) +
  coord_cartesian(xlim = c(as.Date("2026-03-01"), as.Date("2026-04-01")))

It looks like for the most part, the model underestimates the total passengers compared to the true data. Random dips in total passengers probably played a part in driving the prediction down.

The only model that have Eid as a regressor did adjust for Eid (at 15 March 2026 - 27 March 2026). It didn’t look good at the beginning, but fits better at the later half of the holiday. Do keep in mind that we only have 2 Eid in this case as the training set.

TRANSJAKARTA

First Pass

train_tj <- df_tj %>% 
  filter(tanggal <= max(tanggal) - 56)

fits_tj <- train_tj %>% 
  model(
    snaive = SNAIVE(jumlah_penumpang ~ lag("week")),
    ets    = ETS(jumlah_penumpang),
    arima  = ARIMA(jumlah_penumpang),
    arima_eid = ARIMA(jumlah_penumpang ~ is_eid)
  )
fits_tj %>% 
  select(ets) %>% 
  report()
Series: jumlah_penumpang 
Model: ETS(A,N,A) 
  Smoothing parameters:
    alpha = 0.4393207 
    gamma = 0.0001000017 

  Initial states:
     l[0]      s[0]     s[-1]    s[-2]    s[-3]    s[-4]    s[-5]    s[-6]
 965217.1 -336628.5 -244980.9 91437.21 119748.3 139110.5 139734.4 91578.96

  sigma^2:  18376201587

     AIC     AICc      BIC 
24109.52 24109.80 24156.30 

We have the same ETS as KRL, but we have a stronger alpha and a much weaker gamma compared to ETS model for KRL. So, stronger contribution from previous value and much weaker contribution from the last period value.

fits_tj %>% 
  select(ets) %>% 
  components() %>% 
  autoplot()
Warning: Removed 20 rows containing missing values or values outside the scale range
(`geom_line()`).

The seasonality looks constant, which is the reason why gamma is so low.

fits_tj %>% 
  select(arima) %>% 
  report()
Series: jumlah_penumpang 
Model: ARIMA(0,0,4)(0,1,1)[7] 

Coefficients:
         ma1     ma2     ma3     ma4     sma1
      0.5201  0.3215  0.2120  0.1109  -0.9166
s.e.  0.0365  0.0402  0.0372  0.0331   0.0191

sigma^2 estimated as 1.755e+10:  log likelihood=-10416.02
AIC=20844.03   AICc=20844.14   BIC=20872.05

Already a much different model with 4 order of moving average. The coefficients seems pretty significant too from a first glance. The model seems to have strong reliance on past white noises and no contirbution from the past value are needed except for seasonal differencing.

fits_tj %>% 
  select(arima_eid) %>% 
  report()
Series: jumlah_penumpang 
Model: LM w/ ARIMA(2,0,0)(0,1,1)[7] errors 

Coefficients:
         ar1     ar2     sma1     is_eid
      0.4487  0.0303  -0.9139  -344508.8
s.e.  0.0366  0.0368   0.0190    45312.4

sigma^2 estimated as 1.638e+10:  log likelihood=-10389.11
AIC=20788.22   AICc=20788.3   BIC=20811.57

A very different components from the model without Eid regressor. Now, we have 2 order of autoregressive coefficients, and the second order looks insignificant. This begs some attempts at different parameters manually. It seems that during Eid, according to the is_eid coefficient, the total passengers on average drops by 344K with 45K standard error. A lot more compared to KRL.

fits_tj <- train_tj %>% 
  model(
    snaive = SNAIVE(jumlah_penumpang ~ lag("week")),
    ets    = ETS(jumlah_penumpang),
    arima  = ARIMA(jumlah_penumpang),
    arima_eid = ARIMA(jumlah_penumpang ~ is_eid),
    arima_eid_adj = ARIMA(jumlah_penumpang ~ is_eid + pdq(1,0,0) + PDQ(0,1,1, period=7) )
  )
fits_tj %>% 
  select(arima_eid_adj) %>% 
  report()
Series: jumlah_penumpang 
Model: LM w/ ARIMA(1,0,0)(0,1,1)[7] errors 

Coefficients:
         ar1     sma1      is_eid
      0.4609  -0.9121  -351346.10
s.e.  0.0336   0.0188    43666.75

sigma^2 estimated as 1.637e+10:  log likelihood=-10389.45
AIC=20786.9   AICc=20786.95   BIC=20805.58

Slightly better AICc, and we get to have one less autoregressive order. Similar process and result for the model components as KRL.

Model Validation

fits_tj %>% 
  select(c(snaive)) %>% 
  gg_tsresiduals()

fits_tj %>% 
  select(c(ets)) %>% 
  gg_tsresiduals()

fits_tj %>% 
  select(c(arima_eid_adj)) %>% 
  gg_tsresiduals()

The general idea is the same as KRL. But this time, ETS and SARIMA overestimations are more severe. The fluctuations also looks more wild for SNAIVE. Naturally, all models are not considered white noise from the residuals check, but let us see the ljung-box test.

fits_tj %>% 
  augment() %>% 
  filter(.model == "arima_eid_adj") %>% 
  features(.innov, ljung_box, lag = 14, dof = 2)
# A tibble: 1 × 4
  jenis_moda   .model        lb_stat lb_pvalue
  <chr>        <chr>           <dbl>     <dbl>
1 TRANSJAKARTA arima_eid_adj    16.3     0.177
fits_tj %>% 
  augment() %>% 
  filter(.model %in% c("ets", "snaive")) %>% 
  features(.innov, ljung_box, lag = 14, dof = 0)
# A tibble: 2 × 4
  jenis_moda   .model lb_stat   lb_pvalue
  <chr>        <chr>    <dbl>       <dbl>
1 TRANSJAKARTA ets       55.8 0.000000643
2 TRANSJAKARTA snaive   479.  0          

Surprisingly, SARIMA model still manages to fail rejecting the null hypothesis while ETS and SNAIVE still rejects. Meaning, the ljung-box test for SARIMA residuals failed to reject that it is white noise with 95% confidence. Same conclusion as KRL model.

Cross Validation

tj_folds <- df_tj %>% 
  select(-jenis_moda) %>% 
  stretch_tsibble(.init = 500, .step = 7)

cv_fits_tj <- tj_folds %>% 
  model(
    snaive      = SNAIVE(jumlah_penumpang ~ lag("week")),
    ets         = ETS(jumlah_penumpang),
    arima_eid_adj = ARIMA(jumlah_penumpang ~ 0 + is_eid + pdq(1,0,0) + PDQ(0,1,1, period=7))
  ) 

future_tj <- new_data(tj_folds, 7) %>% 
  mutate(is_eid = as.integer(tanggal %within% eid_dates))
cv_fits_tj %>% 
  forecast(new_data = future_tj) %>% 
  accuracy(df_tj) %>% 
  select(.model, MASE, RMSSE) %>% 
  arrange(MASE)
# A tibble: 3 × 3
  .model         MASE RMSSE
  <chr>         <dbl> <dbl>
1 ets           0.817 0.748
2 arima_eid_adj 0.927 0.690
3 snaive        0.989 0.956

The ETS model performs very similarly to KRL model. But the SARIMA model, in general worse MASE compared to both ETS and better RMSSE compared to both ETS.

cv_fits_tj %>% 
  forecast(new_data = future_tj) %>% 
  accuracy(df_tj, by = c(".model", ".id")) %>% 
  left_join(tj_folds %>% as_tibble() %>% group_by(.id) %>% 
              summarise(origin = max(tanggal)), by = ".id") %>% 
  ggplot(aes(origin, MAE, colour = .model)) +
  geom_line() + sc_y

We can see here that SARIMA model seems to carries the effect from the spike and doesn’t recover quick enough and affects the MAE greatly. It makes sense considering that the non-seasonal component is an AR component. However, as seen during 2026 Eid, the SARIMA handles the change much better compared to the other models, which again, the trade-off that was mentioned.

cv_fits_tj %>% 
  forecast(new_data = future_tj) %>% 
  as_tibble() %>% 
  ggplot(aes(tanggal, .mean, colour = .model, group = interaction(.model))) +
  geom_line(linewidth = 0.3) +
  geom_line(data = df_tj, aes(tanggal, jumlah_penumpang), 
            colour = "black", linewidth = 0.4, inherit.aes = FALSE) +
  coord_cartesian(xlim = c(as.Date("2026-03-01"), as.Date("2026-04-01")))

The SARIMA immediately followed the dip from the data due to the information from the regressor, while the other two are quite late at following the pattern because they did not have that information.

MRT

First Pass

train_mrt <- df_mrt %>% 
  filter(tanggal <= max(tanggal) - 56)

fits_mrt <- train_mrt %>% 
  model(
    snaive = SNAIVE(jumlah_penumpang ~ lag("week")),
    ets    = ETS(jumlah_penumpang),
    arima  = ARIMA(jumlah_penumpang),
    arima_eid = ARIMA(jumlah_penumpang ~ is_eid)
  )
fits_mrt %>% 
  select(ets) %>% 
  report()
Series: jumlah_penumpang 
Model: ETS(A,Ad,A) 
  Smoothing parameters:
    alpha = 0.2470292 
    beta  = 0.0001002164 
    gamma = 0.001449496 
    phi   = 0.9293518 

  Initial states:
    l[0]     b[0]      s[0]     s[-1]    s[-2]    s[-3]    s[-4]    s[-5]
 79999.7 3074.014 -40582.49 -34762.83 15215.75 16517.15 19659.82 18006.44
   s[-6]
 5946.15

  sigma^2:  464435019

     AIC     AICc      BIC 
21188.47 21188.94 21249.29 

Now the ETS looks different. We have “Ad” for trend which means additive damped. The additive parameter for trend is denoted by beta, which is really weak in this case which affects current trend. While the damping parameter is denoted by phi, which is very high and close to a linear trend. In other words, the trend only happens for a very short time. It is possible that we can ignore this and have the trend be null instead.

Other than that, we have low alpha and very low gamma, similar to KRL model.

fits_mrt %>% 
  select(ets) %>% 
  components() %>% 
  autoplot()
Warning: Removed 26 rows containing missing values or values outside the scale range
(`geom_line()`).

As expected, the slope immediately flattens to 0 after a couple of months.

fits_mrt %>% 
  select(arima) %>% 
  report()
Series: jumlah_penumpang 
Model: ARIMA(1,0,1)(0,1,1)[7] 

Coefficients:
         ar1      ma1     sma1
      0.7480  -0.4125  -0.9305
s.e.  0.0663   0.0904   0.0186

sigma^2 estimated as 446879276:  log likelihood=-8971.27
AIC=17950.54   AICc=17950.59   BIC=17969.22

Now we have 1 order of each autoregressive and moving average. While the seasonal components seems to be pretty stable across all transportation.

fits_mrt %>% 
  select(arima_eid) %>% 
  report()
Series: jumlah_penumpang 
Model: LM w/ ARIMA(1,0,1)(0,1,1)[7] errors 

Coefficients:
         ar1      ma1     sma1      is_eid
      0.4703  -0.1771  -0.9096  -47918.352
s.e.  0.1097   0.1204   0.0181    6110.755

sigma^2 estimated as 423695760:  log likelihood=-8948.94
AIC=17907.88   AICc=17907.96   BIC=17931.23

We see that the moving average coefficient significance is dwindling due to the standard error increasing. We could apply the same treatment, since if we do \[ -0.1711 \pm 1.96 (0.1204) \] The confidence interval which is \((-0.407, 0.0649)\) includes zero.

fits_mrt <- train_mrt %>% 
  model(
    snaive = SNAIVE(jumlah_penumpang ~ lag("week")),
    ets    = ETS(jumlah_penumpang),
    ets_adj = ETS(jumlah_penumpang ~ error("A") + trend("N") + season("A")),
    arima  = ARIMA(jumlah_penumpang),
    arima_eid = ARIMA(jumlah_penumpang ~ is_eid),
    arima_eid_adj = ARIMA(jumlah_penumpang ~ is_eid + pdq(1,0,0) + PDQ(0,1,1, period=7) )
  )
fits_mrt %>% 
  select(ets_adj) %>% 
  report()
Series: jumlah_penumpang 
Model: ETS(A,N,A) 
  Smoothing parameters:
    alpha = 0.2448389 
    gamma = 0.0001000265 

  Initial states:
     l[0]      s[0]     s[-1]    s[-2]   s[-3]    s[-4]    s[-5]    s[-6]
 97554.29 -39564.28 -33030.41 15217.24 16516.7 19654.38 18006.42 3199.936

  sigma^2:  466332777

     AIC     AICc      BIC 
21188.76 21189.04 21235.54 

Removing the trend component basically does not affect the AICc, but we got rid of the nonsensical trend component as seen from the component figures.

fits_mrt %>% 
  select(arima_eid_adj) %>% 
  report()
Series: jumlah_penumpang 
Model: LM w/ ARIMA(1,0,0)(0,1,1)[7] errors 

Coefficients:
         ar1     sma1      is_eid
      0.3081  -0.9048  -48960.543
s.e.  0.0351   0.0177    5786.804

sigma^2 estimated as 424475423:  log likelihood=-8949.99
AIC=17907.99   AICc=17908.04   BIC=17926.67

AICc barely affected, but we get to remove a coefficient. According to this model, during eid, the average total passengers drops by ~49K with 5K error.

Model Validation

fits_mrt %>% 
  select(c(snaive)) %>% 
  gg_tsresiduals()

fits_mrt %>% 
  select(c(ets_adj)) %>% 
  gg_tsresiduals()

fits_mrt %>% 
  select(c(arima_eid_adj)) %>% 
  gg_tsresiduals()

Both SNAIVE and ETS residuals are quite consistent throughout the transportation methods. However, for SARIMA here, the ACF lines did not cross the blue line, which indicates that the residuals are white noise.

fits_mrt %>% 
  augment() %>% 
  filter(.model == "arima_eid_adj") %>% 
  features(.innov, ljung_box, lag = 14, dof = 2)
# A tibble: 1 × 4
  jenis_moda .model        lb_stat lb_pvalue
  <chr>      <chr>           <dbl>     <dbl>
1 MRT        arima_eid_adj    12.3     0.425
fits_mrt %>% 
  augment() %>% 
  filter(.model %in% c("ets", "snaive", "ets_adj")) %>% 
  features(.innov, ljung_box, lag = 14, dof = 0)
# A tibble: 3 × 4
  jenis_moda .model  lb_stat  lb_pvalue
  <chr>      <chr>     <dbl>      <dbl>
1 MRT        ets        52.1 0.00000273
2 MRT        ets_adj    52.5 0.00000233
3 MRT        snaive    327.  0         

Again, only SARIMA failed to reject the null hypothesis. Also, since there is not much different between ETS and ETS without trend component, we will use ETS without trend as it is simpler, and it is correct to use according to the interpretation of the components.

Cross Validation

mrt_folds <- df_mrt %>% 
  select(-jenis_moda) %>% 
  stretch_tsibble(.init = 500, .step = 7)

cv_fits_mrt <- mrt_folds %>% 
  model(
    snaive      = SNAIVE(jumlah_penumpang ~ lag("week")),
    ets_adj         = ETS(jumlah_penumpang ~ error("A") + trend("N") + season("A")),
    arima_eid_adj = ARIMA(jumlah_penumpang ~ 0 + is_eid + pdq(1,0,0) + PDQ(0,1,1, period=7))
  ) 

future_mrt <- new_data(mrt_folds, 7) %>% 
  mutate(is_eid = as.integer(tanggal %within% eid_dates))
cv_fits_mrt %>% 
  forecast(new_data = future_mrt) %>% 
  accuracy(df_mrt) %>% 
  select(.model, MASE, RMSSE) %>% 
  arrange(MASE)
# A tibble: 3 × 3
  .model         MASE RMSSE
  <chr>         <dbl> <dbl>
1 arima_eid_adj 0.902 0.789
2 ets_adj       0.958 0.845
3 snaive        1.11  1.07 

Interestingly, SARIMA did better in both MASE, RMSSE. Suggesting that the model performs better in general and during events compared to ETS.

cv_fits_mrt %>% 
  forecast(new_data = future_mrt) %>% 
  accuracy(df_mrt, by = c(".model", ".id")) %>% 
  left_join(mrt_folds %>% as_tibble() %>% group_by(.id) %>% 
              summarise(origin = max(tanggal)), by = ".id") %>% 
  ggplot(aes(origin, MAE, colour = .model)) +
  geom_line() + sc_y

We can see that SARIMA and ETS MAE overlaps pretty often except during events. A reason for ETS performing worse in MAE might be because of the total passengers being low for MRT.

cv_fits_mrt %>% 
  forecast(new_data = future_mrt) %>% 
  as_tibble() %>% 
  ggplot(aes(tanggal, .mean, colour = .model, group = interaction(.model))) +
  geom_line(linewidth = 0.3) +
  geom_line(data = df_mrt, aes(tanggal, jumlah_penumpang), 
            colour = "black", linewidth = 0.4, inherit.aes = FALSE) +
  coord_cartesian(xlim = c(as.Date("2026-03-01"), as.Date("2026-04-01")))

Same insight, SARIMA follows the event according to the specified date, and ETS followed it after the event.

Conclusion

We can conclude that for KRL and TRANSJAKARTA, there are two possible best models depending on the purpose of the forecast. The ETS model achieves the best MASE (0.802 and 0.817), while the SARIMA model has the best RMSSE (0.722 and 0.690). The ETS model performs the best when forecasting the typical week, while the SARIMA model handles known events better due to the Eid regressor term. For MRT however, the SARIMA model wins in both MASE (0.902) and RMSSE (0.789) so there is no trade-off here.

The main thing that differentiates the ETS model and SARIMA model, aside from the model structure, is the regressor term that we added. It allows the SARIMA model to follow the change that happens during the event immediately, instead of adjusting after the event happened like the ETS model did.

However, in this dataset, the training data only has two Eid so the result might not be optimal. Not to mention, there are one-offs that happened, the August 2025 riot, the November 2024 election holiday (should not be one-offs but with this dataset, they are) and data inconsistencies around Nataru that we had to impute against the BPS data so it only approximates the daily total passengers shape. With a cleaner data around Nataru and more data, Nataru might be added as a regressor term for SARIMA which might change the model performance as well.