COVID-19が世界中に感染拡大し、日本含め多くの国で外出や集会の制限(自粛)措置が取られて久しい昨今ですが、これに伴って多くのところでCOVID-19に関連したオープンデータが公開されるようになっており、データ分析を生業とする人間が実データを扱う良い機会ともなっているように見受けられます。
ということで、今回の記事では東京都が公開している日次のCOVID-19感染者(PCR検査陽性者)報告数のデータを題材として、時系列モデリングのおさらいをしてみようと思います。なお、この記事における時系列モデリング結果は今後のCOVID-19の感染拡大状況について何かしらの解釈や予測をするためのものでは全くありません*1ので、悪しからずご了承ください。
また、この記事で公開しているコードは以前書いたクソコードをそのまま転用しているので、端的に言ってただのクソコードです。皆さん自身がお試しになる際は是非書き直すことをお薦めしますorz
Rのstl関数を用いて季節調整とトレンド抽出を行ってみる
Rにはstlというtsクラスを引数に取る簡単な時系列分析の関数があるので、これでさっくり季節調整とトレンド抽出をやってみましょう。これは以下のRコードを実行するだけです。
# Download the raw CSV file d <- read.csv('https://stopcovid19.metro.tokyo.lg.jp/data/130001_tokyo_covid19_patients.csv') # Extract date, with adding a dummy d1 <- data.frame(day = d[, 5], num = 1) # Aggregate as daily sum library(dplyr) d1$day <- as.Date(d1$day) # Put date into Date class # Aggregate with group-by d2 <- d1 %>% group_by(day) %>% summarise(sum(num)) %>% as.data.frame() names(d2)[2] <- 'num' # Set up a consecutive date vector dayseq <- seq(from = as.Date(d2$day[1]), to = as.Date(d2$day[nrow(d2)]), by = 'day') dayseq <- as.data.frame(dayseq) names(dayseq) <- 'day' # Join daily sum over the date vector d3 <- left_join(dayseq, d2, by = 'day') # Fill NAs by 0 d3[which(is.na(d3$num)), 2] <- 0 # Model trend and seasonality using stl (loess) dts <- stl(ts(as.numeric(d3$num), frequency = 7), s.window = 'per') # Create a title with start and end dates mtitle <- paste0('Tokyo, daily from ', d3$day[1], ' to ', d3$day[nrow(d3)]) # Plot raw, trend and trend + seasonality time series plot(d3$day, d3$num, main = mtitle, xlab = 'Date', ylab = 'Positive reported', type = 'l', ylim = c(-10, 230), col = 'black', lwd = 1) par(new = T) plot(d3$day, dts$time.series[, 2], main = mtitle, xlab = 'Date', ylab = 'Positive reported', type = 'l', ylim = c(-10, 230), col = 'blue', lwd = 3) par(new = T) plot(d3$day, dts$time.series[, 2] + dts$time.series[, 1], main = mtitle, xlab = 'Date', ylab = 'Positive reported', type = 'l', col = 'red', ylim = c(-10, 230), lty = 2) legend('topleft', legend = c('Reported', 'Trend', 'Trend + Seasonality'), col = c('black', 'blue', 'red'), lty = c(1, 1, 3), lwd = c(1, 3, 2), ncol = 1)
4月29日15時時点で公開されているデータを用いた限りではこんな感じになります。ただ、stlではLOESSを使って「周期成分の振幅は一定」という仮定のもとで季節調整を行っているため、周期成分が絶対値の小さな初期フェーズであっても算入されてしまっていて少しおかしなことになってしまっています。
RStanで一階差分トレンドモデルを推定してみる
そこで、一足飛びに今度はRStanで一階差分トレンド*2モデルの推定をやってみます。これも以下のStanコードとRコードを用意して、ただ回すだけです。
data { int<lower=0> N; real<lower=0> y[N]; } parameters { real season[N]; real trend[N]; real s_trend; real s_season; real s_q; } model { real q[N]; for (i in 7:N) season[i] ~ normal(-season[i - 1] - season[i - 2] - season[i - 3] - season[i - 4] - season[i - 5] - season[i - 6], s_season); for (i in 2:N) trend[i] ~ normal(trend[i - 1], s_trend); for (i in 1:N) q[i] = y[i] - season[i] - trend[i]; for (i in 1:N) q[i] ~ normal(0, s_q); }
# Download the raw CSV file d <- read.csv('https://stopcovid19.metro.tokyo.lg.jp/data/130001_tokyo_covid19_patients.csv') # Extract date, with adding a dummy d1 <- data.frame(day = d[, 5], num = 1) # Aggregate as daily sum library(dplyr) d1$day <- as.Date(d1$day) # Put date into Date class # Aggregate with group-by d2 <- d1 %>% group_by(day) %>% summarise(sum(num)) %>% as.data.frame() names(d2)[2] <- 'num' # Set up a consecutive date vector dayseq <- seq(from = as.Date(d2$day[1]), to = as.Date(d2$day[nrow(d2)]), by = 'day') dayseq <- as.data.frame(dayseq) names(dayseq) <- 'day' # Join daily sum over the date vector d3 <- left_join(dayseq, d2, by = 'day') # Fill NAs by 0 d3[which(is.na(d3$num)), 2] <- 0 # Set up Stan env library(rstan) options(mc.cores = parallel::detectCores()) rstan_options(auto_write = TRUE) # Data as a list N <- nrow(d3) y <- d3$num dat <- list(N = N, y = y) # Fit by Stan fit <- stan('covid19_tokyo_local_trend.stan', data = dat, iter = 1000, chains = 4) # Extract MCMC samples to obtain MLE parameters fit.smp<-extract(fit) trend <- rep(0, N) for (i in 1:N) { tmp <- density(fit.smp$trend[, i]) trend[i] <- tmp$x[tmp$y == max(tmp$y)] } season <- rep(0, N) for (i in 1:N) { tmp <- density(fit.smp$season[, i]) season[i] <- tmp$x[tmp$y == max(tmp$y)] } # Fitted time series pred <- trend + season # Create a title with start and end dates mtitle <- paste0('Tokyo, daily from ', d3$day[1], ' to ', d3$day[nrow(d3)]) # Plot matplot(cbind(y, pred, trend), type = 'l', lty = c(1, 3, 1), lwd = c(1, 2, 3), col = c(1, 2, 4), ylab = '', main = mtitle) legend('topleft', legend = c('Reported', 'Local trend + seasonality', 'Local Trend'), lty = c(1, 3, 1), lwd = c(1, 2, 3), col = c(1, 2, 4))
季節調整の振幅が時変になり、トレンドの抽出も出来ていますが、ちょっとトレンドがギザギザして推定されてしまっているようです。
RStanで二階差分トレンドモデルを推定してみる
そこで、ダメ押しでRStanで二階差分トレンドモデルを推定してみます。これはStanコードを見れば分かりますが「傾きの『変化量』が確率的にばらつく」という仮定を置くもので、一階差分トレンドよりもさらに滑らかに変化するトレンドを推定するものです。
data { int<lower=0> N; real<lower=0> y[N]; } parameters { real season[N]; real trend[N]; real s_trend; real s_season; real s_q; } model { real q[N]; real cum_trend[N]; for (i in 7:N) season[i] ~ normal(-season[i - 1] - season[i - 2] - season[i - 3] - season[i - 4] - season[i - 5] - season[i - 6], s_season); for (i in 3:N) trend[i] ~ normal(2 * trend[i - 1] - trend[i - 2], s_trend); cum_trend[1] = trend[1]; for (i in 2:N) cum_trend[i] = cum_trend[i - 1] + trend[i]; for (i in 1:N) q[i] = y[i] - season[i] - cum_trend[i]; for (i in 1:N) q[i] ~ normal(0, s_q); }
# Download the raw CSV file d <- read.csv('https://stopcovid19.metro.tokyo.lg.jp/data/130001_tokyo_covid19_patients.csv') # Extract date, with adding a dummy d1 <- data.frame(day = d[, 5], num = 1) # Aggregate as daily sum library(dplyr) d1$day <- as.Date(d1$day) # Put date into Date class # Aggregate with group-by d2 <- d1 %>% group_by(day) %>% summarise(sum(num)) %>% as.data.frame() names(d2)[2] <- 'num' # Set up a consecutive date vector dayseq <- seq(from = as.Date(d2$day[1]), to = as.Date(d2$day[nrow(d2)]), by = 'day') dayseq <- as.data.frame(dayseq) names(dayseq) <- 'day' # Join daily sum over the date vector d3 <- left_join(dayseq, d2, by = 'day') # Fill NAs by 0 d3[which(is.na(d3$num)), 2] <- 0 # Set up Stan env library(rstan) options(mc.cores = parallel::detectCores()) rstan_options(auto_write = TRUE) # Data as a list N <- nrow(d3) y <- d3$num dat <- list(N = N, y = y) # Fit by Stan fit <- stan('covid19_tokyo_2nd_trend.stan', data = dat, iter = 1000, chains = 4) # Extract MCMC samples to obtain MLE parameters fit.smp<-extract(fit) trend <- rep(0, N) for (i in 1:N) { tmp <- density(fit.smp$trend[, i]) trend[i] <- tmp$x[tmp$y == max(tmp$y)] } season <- rep(0, N) for (i in 1:N) { tmp <- density(fit.smp$season[, i]) season[i] <- tmp$x[tmp$y == max(tmp$y)] } # Fitted time series pred <- cumsum(trend) + season # Create a title with start and end dates mtitle <- paste0('Tokyo, daily from ', d3$day[1], ' to ', d3$day[nrow(d3)]) # Plot matplot(cbind(y, pred, cumsum(trend)), type = 'l', lty = c(1, 3, 1), lwd = c(1, 2, 3), col = c(1, 2, 4), ylab = '', main = mtitle) legend('topleft', legend = c('Reported', '2nd-diff Trend + Seasonality', '2nd-diff Trend'), lty = c(1, 3, 1), lwd = c(1, 2, 3), col = c(1, 2, 4))
もう少しトレンドが綺麗で滑らかになり、同時に周期成分も振幅が時変になって初期フェーズで暴れることもなくなり、それっぽい当てはめ結果になりました。