統計コンサルの議事メモ

統計や機械学習の話題を中心に、思うがままに

GAMをもう少し理解したい④

前回の続きです。過去記事はこちらから。

ushi-goroshi.hatenablog.com

ushi-goroshi.hatenablog.com

ushi-goroshi.hatenablog.com

GAMの実装

bakfit

さて bakfit ですが、このサブルーチンはバックフィッティングというアルゴリズムを定義しています。このアルゴリズムは、簡単に言えば「関心のある平滑化対象変数を順番に残差に対してフィッティングしていく」方法です。Wikipediaバックフィッティングのページからアルゴリズムの部分について抜粋しましょう:

f:id:ushi-goroshi:20191125142457p:plain

ポイントは4行目の (a) のところで、


{\displaystyle \mathrm{Smooth} \left[\left\{ y_{i} - \hat{\alpha} - \sum_{k \neq j}\hat{f_{k}}(x_{ik})\right\}_{1}^{N}\right]}

とあるように、目的変数から定数部(線形部分)および関心のある平滑化対象変数"以外"(非線形部分)を引いています。すなわち「平滑化対象変数で説明される部分」と「確率的な誤差」で構成される部分のみを残し、平滑化を行なっていることがわかります。これを非線形としたい変数全てについて収束するまで順次繰り返す、というのがバックフィッティングの流れです。

ちなみにこのバックフィッティング、Wikipediaページでは以下のような説明があります:

the backfitting algorithm is equivalent to the Gauss–Seidel method algorithm for solving a certain linear system of equations.

さらにGauss–Seidel法について調べると(こちら)、これはn元の連立一次方程式を反復的に解くための手法であることが解説されているのですが、その中で以下の記述があります:

The Gauss–Seidel method now solves the left hand side of this expression for x, using previous value for x on the right hand side.

で、これは以下のように解くことができます:


 {\displaystyle x_{i}^{(k+1)}={\frac {1}{a_{ii}}}\left(b_{i}-\sum _{j=1}^{i-1}a_{ij}x_{j}^{(k+1)}-\sum _{j=i+1}^{n}a_{ij}x_{j}^{(k)}\right),\quad i=1,2,\dots ,n.}

ここで括弧の中に着目すると、$b$から$ax$が順次引かれている(二項目と三項目)のですが、総和記号のインデックスには$i$が含まれていません。つまり更新の対象である$i$番目の$x$以外を順番に引いており、バックフィッティングと同じような計算になっていることがわかります。なお括弧の中の三項目は添え字が (k) ですが、二項目では (k+1) になっており、更新後の$x$を直接使っていることがわかります。さらにちなみに、ここで逐次更新するのではなく一度全ての解を求めてから一斉に更新する方法をヤコビ法といいます。

では上記のアルゴリズムbakfit でどのように実装されているのかを確認してみましょう。と言いつつ実は bakfit 自体は基本的に以下のように backf1 を呼び出しているだけです。

subroutine bakfit(x,npetc,y,w,which,spar,dof,match,nef,
                  etal,s,eta,beta,var,tol,
                  qr,qraux,qpivot,effect,work)
implicit double precision(a-h,o-z)
logical ifvar
integer npetc(7),iter
integer n,p,q,which(*),match(*),nef(*),nit,maxit,qrank,qpivot(*)
double precision x(*),y(*),w(*),spar(*),dof(*),
etal(*),s(*),eta(*),beta(*),var(*),tol,
qr(*),qraux(*),effect(*),work(*)
n=npetc(1)
p=npetc(2)
q=npetc(3)
ifvar=.false.
if(npetc(4)==1)ifvar=.true.
maxit=npetc(6)
qrank=npetc(7)
do i=1,q{work(i)=dof(i)}
### ここで backf1 を呼び出している
call backf1(x,n,p,y,w,q,which,spar,dof,match,nef,
            etal,s,eta,beta,var,ifvar,tol,nit,maxit,
            qr,qraux,qrank,qpivot,effect,work(q+1),work(q+n+1),
            work(q+2*n+1),work(q+3*n+1),work(q+4*n+1))

npetc(7)=qrank
return
end

なので backf1 を確認することにしましょう。

backf1

前回同様まずは backf1 の処理全体をさっと眺めて見通しを良くします。 backf1 ではざっくりと

  1. 前処理
  2. バックフィッティング
  3. eta を再定義

という処理が行われます。

subroutine backf1(x,n,p,y,w,q,which,spar,dof,match,nef,
                  etal,s,eta,beta,var,ifvar,tol,nit,maxit,
                  qr,qraux,qrank,qpivot,effect,z,old,sqwt,sqwti,work)
implicit double precision(a-h,o-z)
logical ifvar
integer n,p,q,which(q),match(n,q),nef(q),nit,maxit,qrank,qpivot(p)
double precision x(n,p),y(n),w(n),spar(q),dof(q),
etal(n),s(n,q),eta(n),beta(p),var(n,q),tol,
qr(n,p),qraux(p),effect(n),work(*)
double precision z(*),old(*),dwrss,ratio
double precision sqwt(n),sqwti(n)
logical anyzwt
double precision deltaf, normf,onedm7
integer job,info

onedm7=1d-7
job=1101;info=1
if(q==0)maxit=1
ratio=1d0

## 1. 前処理
### データに重みをつける
anyzwt=.false.
do i=1,n{
  if(w(i)>0d0){
    sqwt(i)=dsqrt(w(i))
    sqwti(i)=1d0/sqwt(i)
  }
  else{
    sqwt(i)=0d0
    sqwti(i)=0d0
    anyzwt=.true.
  }
}

### QR分解する
if(qrank==0){
  do i=1,n{
    do j=1,p{
      qr(i,j)=x(i,j)*sqwt(i)
    }
  }
  do j=1,p{qpivot(j)=j}
  call dqrdca(qr,n,n,p,qraux,qpivot,work,qrank,onedm7)
}

### eta に 非線形項 s(i,j) を加算する
do i=1,n{
  eta(i)=0d0
  for(j=1;j<=q;j=j+1){
    eta(i)=eta(i)+s(i,j)
  }
}


## 2. バックフィッティング
nit=0
while ((ratio > tol )&(nit < maxit)){
  deltaf=0d0
  nit=nit+1
  
  ### まずは Linear part + error 部分に最小二乗法を当てはめる
  do i=1,n{
    z(i)=(y(i)-eta(i))*sqwt(i) 
    old(i)=etal(i)
  }
  call dqrsl(qr,n,n,qrank,qraux,z,work(1),effect(1),beta,
             work(1),etal,job,info)
  
  ### 重みの逆数で戻し、 etal を更新
  do i=1,n{
    etal(i)=etal(i)*sqwti(i)
  }
  
  ### 次に平滑化対象の変数についてループ
  for(k=1;k<=q;k=k+1){
    j=which(k)
    do i=1,n{
      old(i)=s(i,k)
      z(i)=y(i)-etal(i)-eta(i)+old(i)
    }
    if(nit>1){dof(k)=0d0}
    
    ### splsmで平滑化
    call splsm(x(1,j),z,w,n,match(1,k),nef(k),spar(k),
               dof(k),s(1,k),s0,var(1,k),ifvar,work)
    do i=1,n{ 
      eta(i)=eta(i)+s(i,k)-old(i)
      etal(i)=etal(i)+s0
    }
    
    deltaf=deltaf+dwrss(n,old,s(1,k),w)
  }
  
  ### whileループの打ち切り判定
  normf=0d0
  do i=1,n{
    normf=normf+w(i)*eta(i)*eta(i)
  }
  if(normf>0d0){
    ratio=dsqrt(deltaf/normf)
  }
  else {ratio = 0d0}
}

### 3. eta を再定義
do j=1,p {work(j)=beta(j)}
do j=1,p {beta(qpivot(j))=work(j)}
if(anyzwt){
  do i=1,n {
    if(w(i) <= 0d0){
      etal(i)=0d0
      do j=1,p{
        etal(i)=etal(i)+beta(j)*x(i,j)
      }
    }
  }
}

do i=1,n
eta(i)=eta(i)+etal(i)

do j=1,q {
  call unpck(n,nef(j),match(1,j),var(1,j),old)
  do i=1,n {var(i,j)=old(i)}
}

return
end

3つ目の工程で eta の再定義とあるのですが、これまでの処理(〜 s.wam)において eta は「 y をリンク関数で変換したもの」として扱われてきました。一方 backf1 の中では 「平滑化対象変数によって構成される部分」(非線形/平滑化部分)として扱われており、混乱しやすいので注意が必要です。つまり backf1 の中では y を「線形部分」と「非線形(平滑化)部分」に分割し、前者を etal 、後者を eta として計算が行われます。

では一つずつ見ていきましょう。

1. 前処理

まずは前処理です。ここでは

  • データに重みをつける
  • QR分解する
  • eta非線形部分)を定義する

という処理が行われます。

### weight の平方根をとって逆数にする
anyzwt=.false.
do i=1,n{
  if(w(i)>0d0){
    sqwt(i)=dsqrt(w(i)) ## Fortranの組み込み関数。sqrt()
    sqwti(i)=1d0/sqwt(i) ## weight の逆数
  }
  else{
    sqwt(i)=0d0
    sqwti(i)=0d0
    anyzwt=.true.
  }
}

### qrank が 0 のときは、x に重みを加えた上で QR 分解する
if(qrank==0){
  do i=1,n{
    do j=1,p{
      ### 重みを乗じた x を生成
      qr(i,j)=x(i,j)*sqwt(i) # qr は n*p の行列
    }
  }
  do j=1,p{qpivot(j)=j} ### qvivot は長さ p の integer1~p の数字を格納
  
  ### QR分解。dqrdca のソースは以下
  ### https://github.com/cran/gam/blob/master/inst/ratfor/linear.r
  call dqrdca(qr,n,n,p,qraux,qpivot,work,qrank,onedm7)
}

### eta に 非線形項 s(i,j) を加算する
### ただしここで eta は fitted value ではなく 0 スタート
### s は線形部分とは独立な平滑化部分で、バックフィッティングで更新する
do i=1,n{
  eta(i)=0d0 # 0.0 にリセット 
  for(j=1;j<=q;j=j+1){ # 平滑化対象の変数について
    eta(i)=eta(i)+s(i,j) # 平滑化部分を順次加算していく
  }
}

上のブロックでQR分解を行なっていますが、これは次の工程で線形部分に対して通常の回帰を実行するためです。以前の記事で glm がどのように実装されているかを調べた際、 glm では、

  • dqrdc2 というサブルーチンを用いてQR分解
  • dqrsl というサブルーチンで最小二乗法を当てはめ

という処理で解が得られることを示しましたが、 glm 同様に gam においても線形部分のフィッティングではQR分解によって最小二乗解を求める、という流れになっているようです。

ushi-goroshi.hatenablog.com

2. バックフィッティング

では続いてバックフィッティングの実装を見ていきましょう。ここが、ある意味で gam の本体とも言えるルーチンになります。

### while ループ開始
nit=0
while ((ratio > tol )&(nit < maxit)){ # ratio のデフォルトは 1.0 。 tol は多分 0.0005
  # first the linear fit
  deltaf=0d0
  nit=nit+1 # イテレータに +1
  
  ### まずは Linear part + error 部分に最小二乗法を当てはめる
  ### y から非線形項を減じたもの(Linear part + error)に重みを乗じて z を更新(これを y として dqrsl に渡す)
  # etal を old に格納。ループ1回目の時点では etal は Nulldo i=1,n{
    z(i)=(y(i)-eta(i))*sqwt(i) 
    old(i)=etal(i)
  }
  
  ### https://github.com/wch/r-source/blob/trunk/src/appl/dqrsl.f
  ### dqrsl の6個目の引数として z を渡しているが、これは y になる
  ### etal は11番目の引数として渡しているが、これは xb 。
  call dqrsl(qr,n,n,qrank,qraux,z,work(1),effect(1),beta,
             work(1),etal,job,info)
  
  ### 重みの逆数で戻し、 etal を更新
  ### etal は y の予測値の線形部分
  do i=1,n{
    etal(i)=etal(i)*sqwti(i)
  }

まずは上のブロックで線形部分のフィッティングが行われます。 dqrsl の直前の処理に注目すると、 y から eta を減じたものを z として定義し、これを dqrsl の引数として渡しています。すなわち目的変数から eta非線形部分)を減じた、線形部分ですね。これを、先ほど解説したように dqrsl というサブルーチンに渡し、最小二乗法による解を得ます。引数には eta ではなく etal が入っていることに注意しましょう。この etal は直後の処理で重みを乗じた上で後続の処理に引き渡されます。

続いて平滑化対象変数の処理に入ります。

  ### ここで平滑化対象の変数についてループ
  for(k=1;k<=q;k=k+1){ # 平滑化対象の変数ごとに
    j=which(k)
    do i=1,n{
      ### old に k 番目の平滑化対象変数の値を入れる
      old(i)=s(i,k)
      ### y から etal (Linear part)と eta (Nonlinear part) を引き、old (k 番目の Nonlinear part) を足している
      ### 残差 + k 番目の平滑化変数の値になっている
      z(i)=y(i)-etal(i)-eta(i)+old(i)
    }
    ### df は 0 にリセットされてしまう
    if(nit>1){dof(k)=0d0}
    
    ### splsm を呼びだす
    ### 切片と k 番目の平滑化変数を x として渡す
    ### 9番目の引数 s が splsm 内での平滑化変数 smo なので s が更新される
    call splsm(x(1,j),z,w,n,match(1,k),nef(k),spar(k),
               dof(k),s(1,k),s0,var(1,k),ifvar,work)
    
    do i=1,n{
      ### eta を更新
      ### 古い eta にk番目の非線形部分を加算して、古い非線形部分を減じる)
      eta(i)=eta(i)+s(i,k)-old(i)
      ### etal を更新(古い etal に s0 を乗じる。)
      ### s0 は weighted mean of y
      etal(i)=etal(i)+s0
    }
    ### deltaf を更新(while ループの打ち切り判定)
    deltaf=deltaf+dwrss(n,old,s(1,k),w)
  } # ここまで for ループ

細かな処理はコメントとして書いてありますが、このブロックでは平滑化の対象となる変数について平滑化を実行して s を更新します。ポイントは

  z(i)=y(i)-etal(i)-eta(i)+old(i)

のところで、この記事の冒頭でバックフィッティングのアルゴリズムを解説した際に「関心のある平滑化対象変数を順番に残差に対してフィッティングしていく」と述べましたが、そのために残差を求めているのが上の式になります。 etal は線形部分、 eta非線形部分なので両者を引くと確率的な誤差しか残りませんが、そこに old(i) つまり s(i, k) を加算することで、k番目の平滑化対象変数 + 確率的な誤差のみが残ります。

続いてこの誤差に対して平滑化を行なっているのが

splsm(x(1,j),z,w,n,match(1,k),nef(k),spar(k),
      dof(k),s(1,k),s0,var(1,k),ifvar,work)

の部分です。この splsm は内部でさらに sbart というCのサブルーチンを呼び出しているのですが、この解説を始めるとキリがないのでまた別の機会に取っておきましょう。

平滑化により s が更新されたら、 eta にもk番目の平滑化対象変数の更新を反映させ、最後に while ループの打ち切り判定のための指標を計算します。

### normf の更新(while ループの打ち切り判定)
normf=0d0
do i=1,n{
  normf=normf+w(i)*eta(i)*eta(i)
}
### ratio(while ループの判定に使われる)を計算
if(normf>0d0){
  ratio=dsqrt(deltaf/normf)
}
else {ratio = 0d0}
#         call DBLEPR("ratio",-1,ratio,1)
}
3. eta の再定義

以上の工程で十分に収束したと判断されたらバックフィッティングアルゴリズムは終了し、 s.wam に値を返す処理に入ります。冒頭で解説したように backf1 内では eta非線形部分として扱われていたので、これを線形部分と加算して元の eta に戻します。なお beta は線形部分の各変数の回帰係数です。

### etal を再定義
do j=1,p {work(j)=beta(j)}
do j=1,p {beta(qpivot(j))=work(j)}
if(anyzwt){
  do i=1,n {
    if(w(i) <= 0d0){
      etal(i)=0d0
      do j=1,p{
        etal(i)=etal(i)+beta(j)*x(i,j)
      }
    }
  }
}

### 非線形項 + 線形項として eta を再定義
do i=1,n
eta(i)=eta(i)+etal(i)

### 結果をまとめる    
do j=1,q {
  call unpck(n,nef(j),match(1,j),var(1,j),old)
  do i=1,n {var(i,j)=old(i)}
}

return
end

さて、これで backf1 および bakfit の処理は終了したので s.wam に戻ることになります。 s.wam の残りの工程では、前回記事で確認したように結果を格納したオブジェクトである rl を生成するのでした。それと元々のデータを順位で置き換える形で生成された smooth.frame をまとめて返します。

さらに s.wam を抜けると gam.fit 内でのループに戻ってくるわけですが、これは前々回記事で解説した通り、残差からデビアンスを計算した上でループの打ち切りを判定します。

gam.fit

収束が十分であれば、 gam.fit のループを抜けます。残りの工程は基本的に結果をまとめあげて gam に返すだけのようです。

### gam.fit に戻ってきた
fitqr <- fit$qr
xxnames <- xnames[fitqr$pivot]
nr <- min(sum(good), nvars)
if (nr < nvars) {
  Rmat <- diag(nvars)
  Rmat[1:nr, 1:nvars] <- fitqr$qr[1:nr, 1:nvars]
}
else Rmat <- fitqr$qr[1:nvars, 1:nvars]
Rmat <- as.matrix(Rmat)
Rmat[row(Rmat) > col(Rmat)] <- 0
dimnames(Rmat) <- list(xxnames, xxnames)
names(fit$residuals) <- ynames
names(mu) <- ynames
names(eta) <- ynames

### eta and mu
fit$additive.predictors <- eta
fit$fitted.values <- mu
names(fit$weights) <- ynames
names(fit$effects) <- c(xxnames[seq(len = fitqr$rank)], rep.int("", 
                                                                sum(good) - fitqr$rank))
if (length(fit$smooth) > 0) 
  fit$smooth.frame <- smooth.frame[smooth.labels]
wtdmu <- if (a$intercept) 
  sum(weights * y)/sum(weights)
else linkinv(offset)
nulldev <- sum(dev.resids(y, wtdmu, weights))
n.ok <- nobs - sum(weights == 0)
nulldf <- n.ok - as.integer(a$intercept)
rank <- n.ok - fit$df.residual
aic.model <- aic(y, nobs, mu, weights, new.dev) + 2 * rank
if (!is.null(fit$smooth)) {
  nonzeroWt <- (wz > 0)
  nl.chisq <- gam.nlchisq(fit$qr, fit$residuals, wz, fit$smooth)
}
else nl.chisq <- NULL
fit <- c(fit, list(R = Rmat, rank = fitqr$rank, family = family, 
                   deviance = new.dev, aic = aic.model, null.deviance = nulldev, 
                   iter = iter, prior.weights = weights, y = y, df.null = nulldf, 
                   nl.chisq = nl.chisq))
fit
}

gam

ようやく gam に帰ってきました。 gam.fit を呼び出した以降の処理は以下のブロックだけです。特定の条件下においてNull Deviance(切片のみ考慮されたモデルによる逸脱度)を計算し、結果をまとめあげて返します。

### offset が指定されており intercept 項がある場合
if (length(offset) && attr(mt, "intercept") > 0) {
  fit$null.dev <- glm.fit(x = X[, "(Intercept)", drop = FALSE], 
                          y = Y, weights = weights, offset = offset, family = family, 
                          control = control[c("epsilon", "maxit", "trace")], 
                          intercept = TRUE)$deviance
}
if (model) 
  fit$model <- mf
fit$na.action <- attr(mf, "na.action")
if (x) 
  fit$x <- X
if (!y) 
  fit$y <- NULL
fit <- c(fit, list(call = call, formula = formula, terms = mt, 
                   data = data, offset = offset, control = control, method = method, 
                   contrasts = attr(X, "contrasts"), xlevels = .getXlevels(mt, 
                                                                           mf)))
class(fit) <- c("Gam", "glm", "lm")
if (!is.null(fit$df.residual) && !(fit$df.residual > 0)) 
  warning("Residual degrees of freedom are negative or zero.  This occurs when the sum of the parametric and nonparametric degrees of freedom exceeds the number of observations.  The model is probably too complex for the amount of data available.")
fit
}

終わりに

以上で終わりです!長くなりましたが、ようやく gam の内部で何が行われているかを確認することができました。振り返ると、 gam という関数のコアとなる部分では:

  1. バックフィッティングによる非線形項のフィッティング( bakfit および backf1
  2. バックフィッティングに渡すためにデータを順位に置き換える( s.wam
  3. {general, s, lo}.wam のいずれのエンジンに渡すかを特定し、 bf.call を作成( gam.fit
  4. gam.fit に渡すための諸々の設定( gam

といった処理が行われているようでした。またバックフィッティングというアルゴリズムは、端的には「関心のある変数による影響のみを残してフィッティングすることを繰り返す」というものであることがわかりました。

一方で、今回の一連の記事では平滑化の仕組みを追いかけることがまだ出来ていません。また平滑化の関数にしても s.wam しか見ておらず、 lo.wamgeneral.wam の挙動を確認していません。その辺りもいずれ調べたいですね。