想自己重算,可以從頁首下載 R Markdown 與執行結果。所有實證例都使用隨書提供的固定資料; 檔案來源、變數單位與已知限制請見資料來源與重現說明

《財務時間序列分析》線上附錄

R01:R、tidyverse 與手動 OLS

本附錄對應第 1 章,要回答一個適合初學 R、也很適合檢查公式的問題:把普通最小平方法(ordinary least squares, OLS)的矩陣公式直接寫成程式,會不會得到與 lm() 相同的係數?我們以 AAPL 當日報酬為應變數,將 MSFT 與 NVDA 的等權平均當日報酬作為解釋變數,依序完成資料整理、手動 OLS、套件結果比較與殘差診斷。

資料共有 2,384 個共同交易日,期間為 2013-01-03 至 2022-06-22。每一列是一個交易日,每一欄是一檔股票的日簡單報酬,報酬以小數表示;例如 0.01 代表 1%。解釋變數使用的是與 AAPL 同一天收盤後才完整可得的報酬,因此這裡研究的是同日線性關係,不是盤前或即時預測。全樣本都用於這項代數練習,沒有另分訓練期、驗證期與測試期,也沒有足以支持因果解讀的識別設計。

knitr::opts_chunk$set(
  echo = TRUE, message = FALSE, warning = FALSE,
  fig.width = 7, fig.height = 4.5
)
stopifnot(requireNamespace("dplyr", quietly = TRUE))
stopifnot(requireNamespace("tibble", quietly = TRUE))
library(dplyr)
## 
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
## 
##     filter, lag
## The following objects are masked from 'package:base':
## 
##     intersect, setdiff, setequal, union
library(tibble)

root_candidates <- c(".", "..")
is_root <- vapply(root_candidates, function(x) {
  file.exists(file.path(x, "main.tex"))
}, logical(1))
stopifnot(any(is_root))
project_root <- root_candidates[which(is_root)[1]]
project_path <- function(...) file.path(project_root, ...)

先把觀察單位與排序確認清楚#

讀檔後先只留下本例需要的日期與三檔股票。日期轉成 R 的 Date 類別後再排序,才能確定第 $t-1$ 列真的早於第 $t$ 列。固定面板已保留三檔股票都有報酬的共同交易日,所以這一頁不做填補或刪除缺值。

data_path <- project_path(
  "data", "processed", "sp500_returns_balanced_2013_2022.csv"
)
manifest_path <- project_path("data", "processed", "manifest.csv")
stopifnot(file.exists(data_path), file.exists(manifest_path))

raw <- read.csv(data_path, check.names = FALSE)
returns <- raw |>
  transmute(
    date = as.Date(date),
    AAPL = as.numeric(AAPL),
    MSFT = as.numeric(MSFT),
    NVDA = as.numeric(NVDA)
  ) |>
  arrange(date)

# 若日期重複、順序錯誤或任一欄缺值,先回到資料入口處理。
stopifnot(!anyNA(returns), !anyDuplicated(returns$date))
stopifnot(all(diff(returns$date) > 0))
glimpse(returns)
## Rows: 2,384
## Columns: 4
## $ date <date> 2013-01-03, 2013-01-04, 2013-01-07, 2013-01-08, 2013-01-09, 2013…
## $ AAPL <dbl> -0.012622072, -0.027854471, -0.005882822, 0.002691439, -0.0156289…
## $ MSFT <dbl> -0.0133961372, -0.0187154612, -0.0018699229, -0.0052453126, 0.005…
## $ NVDA <dbl> 0.0007860068, 0.0329928166, -0.0288975086, -0.0219260948, -0.0224…

glimpse() 應顯示 2,384 列與四個欄位;三個報酬欄都是數值,日期則為 Date。資料由原課程價格長表建立,建檔時先在每一檔股票內依日期計算報酬,再保留 89 檔都有觀察值的共同交易日。來源、建檔方式與 MD5 記錄見 data/DATA_SOURCES.md

向量、資料框與管線#

接下來先熟悉兩個最常用的 R 物件。first_five 是只含五個數字的向量,returns 則是每列代表一個交易日的資料框;先分清楚物件的形狀,後面建立設計矩陣時才不會把列與欄顛倒。

first_five <- returns$AAPL[1:5]
object_summary <- tibble(
  object = c("first_five", "returns"),
  class = c(class(first_five)[1], class(returns)[1]),
  length_or_rows = c(length(first_five), nrow(returns))
)
object_summary
## # A tibble: 2 × 3
##   object     class      length_or_rows
##   <chr>      <chr>               <int>
## 1 first_five numeric                 5
## 2 returns    data.frame           2384

現在用 mutate() 建立 MSFT 與 NVDA 的等權平均報酬 tech_equal,再用 summarise() 按年度整理 AAPL 的觀察值數、平均數與標準差。年度表的每一列是一個曆年;因原檔已是簡單報酬,mean_aaplsd_aapl 仍以小數表示,而不是百分比。

analysis_df <- returns |>
  mutate(
    tech_equal = (MSFT + NVDA) / 2,
    year = as.integer(format(date, "%Y"))
  )

annual_summary <- analysis_df |>
  group_by(year) |>
  summarise(
    observations = n(),
    mean_aapl = mean(AAPL),
    sd_aapl = sd(AAPL),
    .groups = "drop"
  )
annual_summary
## # A tibble: 10 × 4
##     year observations  mean_aapl sd_aapl
##    <int>        <int>      <dbl>   <dbl>
##  1  2013          251  0.000347   0.0179
##  2  2014          252  0.00145    0.0136
##  3  2015          252  0.0000199  0.0168
##  4  2016          252  0.000575   0.0147
##  5  2017          251  0.00164    0.0111
##  6  2018          251 -0.0000573  0.0181
##  7  2019          252  0.00266    0.0165
##  8  2020          253  0.00281    0.0294
##  9  2021          252  0.00131    0.0158
## 10  2022          118 -0.00202    0.0227

這張表先讓我們看出各年交易日數與波動是否大致合理。若某一年觀察值突然很少,應先檢查資料涵蓋與共同交易日規則,再進入迴歸;年度平均數的正負也只是樣本描述,不宜當作穩定的未來報酬率。

手動 OLS#

令 $y$ 為 AAPL 當日報酬,$x$ 為 MSFT、NVDA 的等權平均當日報酬。設計矩陣 $X$ 的每一列對應一個共同交易日,第一欄全為 1,用來估計截距,第二欄才是 tech_equal。在計算 $(X^\top X)^{-1}X^\top y$ 以前,先確認 $X$ 的列數與 $y$ 的長度相同,而且兩欄線性獨立。

y <- analysis_df$AAPL
X <- cbind(Intercept = 1, tech_equal = analysis_df$tech_equal)

# 維度不合或設計矩陣不滿秩時,OLS 公式不能照寫下去。
stopifnot(nrow(X) == length(y), qr(X)$rank == ncol(X))

beta_manual <- solve(crossprod(X), crossprod(X, y))
fitted_manual <- as.numeric(X %*% beta_manual)
residual_manual <- y - fitted_manual

tibble(
  term = rownames(beta_manual),
  estimate = as.numeric(beta_manual)
)
## # A tibble: 2 × 2
##   term       estimate
##   <chr>         <dbl>
## 1 Intercept  0.000124
## 2 tech_equal 0.569

crossprod(X) 等於 $X^\top X$,crossprod(X, y) 等於 $X^\top y$。輸出中的截距約為 0.000124,斜率約為 0.5693。就這份樣本而言,MSFT 與 NVDA 的等權平均當日報酬每增加 1 個百分點,AAPL 的線性投影值約增加 0.569 個百分點;這是同日共同波動的條件關聯,不表示前兩檔股票造成 AAPL 上漲。正式計算通常偏好 QR 分解,因為直接解正規方程式在近共線時較不穩定;此處保留矩陣公式,是為了讓課文中的推導與 R 運算一一對上。

lm() 與 QR 分解比較同一個答案#

手動結果算出後,再讓 qr.solve()lm() 解同一個問題。三欄係數的名稱與次序必須一致,否則即使數字相近,也可能是在比較不同參數。

fit_lm <- lm(AAPL ~ tech_equal, data = analysis_df)
beta_qr <- qr.solve(X, y)

comparison <- tibble(
  term = names(coef(fit_lm)),
  manual = as.numeric(beta_manual),
  qr = as.numeric(beta_qr),
  lm = as.numeric(coef(fit_lm))
)
comparison
## # A tibble: 2 × 4
##   term          manual       qr       lm
##   <chr>          <dbl>    <dbl>    <dbl>
## 1 (Intercept) 0.000124 0.000124 0.000124
## 2 tech_equal  0.569    0.569    0.569
# 三種演算法應在浮點計算容許範圍內得到相同係數。
stopifnot(
  isTRUE(all.equal(as.numeric(beta_manual), as.numeric(beta_qr))),
  isTRUE(all.equal(as.numeric(beta_manual), as.numeric(coef(fit_lm))))
)

comparison 的三個係數欄應重合。這項結果說明我們正確地把 OLS 公式翻成 R;它沒有比較模型好壞,也沒有替殘差的時間序列性質背書。

正規方程式與配適分解#

含截距的 OLS 殘差應與設計矩陣的每一欄正交。這裡直接計算 $X^\top \hat e$,也把總平方和分成模型解釋部分與殘差平方和,確認係數、配適值與殘差彼此一致。

orthogonality <- as.numeric(crossprod(X, residual_manual))
names(orthogonality) <- colnames(X)
orthogonality
##    Intercept   tech_equal 
## 5.342948e-16 2.107689e-16
stopifnot(max(abs(orthogonality)) < 1e-10)

sst <- sum((y - mean(y))^2)
sse <- sum(residual_manual^2)
ssr <- sum((fitted_manual - mean(y))^2)
c(SST = sst, explained = ssr, SSE = sse, difference = sst - ssr - sse)
##           SST     explained           SSE    difference 
##  7.771318e-01  2.989755e-01  4.781562e-01 -4.996004e-16

orthogonality 的兩個數應非常接近零,平方和表中的 difference 也應只剩浮點誤差。若差距明顯,應先檢查是否漏了截距、資料列是否錯位,或配適值與殘差是否來自同一個模型。

在計算以前先決定如何處理缺值#

固定面板目前沒有缺值,但換成別份資料時,刪除觀察值可能改變樣本期間與研究對象。以下函數的一般設定是遇到缺值就停止;只有呼叫者把 allow_drop 明確設為 TRUE 時,才保留指定欄位都完整的列。把決定集中在資料準備階段,比在每一個統計量中分別加入 na.rm = TRUE 更容易看出實際用了哪些觀察值。

prepare_complete <- function(data, columns, allow_drop = FALSE) {
  stopifnot(all(columns %in% names(data)))
  ok <- complete.cases(data[, columns, drop = FALSE])
  # 先要求研究者選擇處理方式,避免各函數各自悄悄丟掉不同列。
  if (!all(ok) && !allow_drop) {
    stop("指定欄位含缺值;請決定刪除、填補或修正來源。")
  }
  data[ok, , drop = FALSE]
}

toy <- tibble(y = c(1, 2, NA), x = c(0, 1, 2))
expected_error <- try(
  prepare_complete(toy, c("y", "x")),
  silent = TRUE
)
inherits(expected_error, "try-error")
## [1] TRUE
prepare_complete(toy, c("y", "x"), allow_drop = TRUE)
## # A tibble: 2 × 2
##       y     x
##   <dbl> <dbl>
## 1     1     0
## 2     2     1

第一次呼叫應得到預期錯誤,第二次則只留下前兩列。實際研究若允許刪除,還要報告刪除了多少列、落在哪些日期,以及缺值是否可能與市場狀況有關。

診斷圖#

係數相同只代表三種計算法一致,還不能回答線性模型是否充分。殘差對配適值圖用來找非線性或變異程度改變,常態 Q–Q 圖則讓我們觀察尾端是否偏離常態;兩張圖的每一點仍對應一個共同交易日。

par(mfrow = c(1, 2))
plot(
  fitted_manual, residual_manual,
  xlab = "配適值", ylab = "殘差",
  pch = 16, cex = 0.45, col = "#173B57"
)
abline(h = 0, lty = 2, col = "#A34045")
qqnorm(residual_manual, pch = 16, cex = 0.45, col = "#173B57")
qqline(residual_manual, col = "#A34045", lwd = 2)

OLS 殘差與配適值;此圖只診斷線性投影,不代表預測或因果。

par(mfrow = c(1, 1))

圖形若呈現厚尾、極端點或波動群聚,OLS 係數的代數結果仍成立,但傳統獨立同分配標準誤便未必合適。下一步應檢查殘差的自相關與條件異質變異,並視研究目的採用時間序列穩健推論或重新指定模型。

這份練習回答了什麼#

這份固定資料顯示 AAPL 與另外兩檔大型科技股的同日報酬具有正向線性關聯,而且手動公式、QR 分解與 lm() 給出相同係數。它還沒有告訴我們這項關聯在未來是否穩定,也沒有因果含意。若要把問題改成預測,解釋變數必須在預測起點已經可得,例如用第 $t$ 日資訊預測第 $t+1$ 日報酬,並依第 8 章以時間排序的訓練期、驗證期與測試期評估。

© 陳釗而 《財務時間序列分析》