餡子付゛録゛

ソフトウェア開発ツールの便利な使い方を紹介。

FortranでRの対数尤度関数とその一階微分を用意すると

Rもだいたい線形代数の範囲で片付けている限りは、そこそこ速いことがわかります。Fortranとの速度差が出ないというか、R vs R/FortranだとRの方が微妙に速く*1。

遅くなるのになぜFortranでRの対数尤度関数とその一階微分を用意したかと言うと、Fortranでハミルトニアン・モンテカルロ法を試みるために必要なコードで、準備として整備する必要があるからです。中間段階の副産物として、大してループが無い行列の計算でまとまるものは、Rで書いておけば済むことがわかったので記します*2。

正規分布の対数尤度関数とその解析的な一階微分

BLASを使うか否かを迷ったのですが、非BLASの方が速かったので非BLAS版を使いました。

module norm_d
	implicit none
	contains
		double precision function llf_norm(nr, nc, X, y, p)
			implicit none
			integer, intent(in) :: nr, nc
			double precision, dimension(nr, nc), intent(in) :: X
			double precision, dimension(nr), intent(in) :: y
			double precision, dimension(nc + 1), intent(in) :: p
			integer :: i, j
			double precision, dimension(nr) :: s

			!$omp parallel
			!$omp do
			do i = 1, nr
				s(i) = dlog(dnorm(y(i) - sum(X(i, :)*p(2: 1 + nc)),p(1)))
			end do
			!$omp end do
			!$omp end parallel

			llf_norm = sum(s)
		end function

		double precision function dnorm(x, sigma)
			implicit none
			double precision, intent(in) :: x, sigma
			double precision, parameter :: pi = 3.141592653589793115998
			dnorm = dexp(-x**2/2/sigma**2)/dsqrt(2*pi*sigma**2)
		end function

		subroutine llfg_norm(nr, nc, X, y, p, g)
			implicit none
			integer, intent(in) :: nr, nc
			double precision, dimension(nr, nc), intent(in) :: X
			double precision, dimension(nr), intent(in) :: y
			double precision, dimension(nc + 1), intent(in) :: p
			double precision, dimension(nc + 1), intent(out) :: g
			double precision, dimension(nc + 1) :: q 
			double precision :: theta, dtheta
			double precision, dimension(nc) :: beta, dbeta
			double precision, dimension(nr) :: res
			integer :: i

			beta(:) = p(2:nc + 1)
			theta = p(1)

			! BLAS
			! res(:) = y(:)
			! call dgemm('n', 'n', nr, 1, nc, -1d0, X, nr, beta, nc, 1d0, res, nr)

			! No BLAS
			!$omp parallel
			!$omp do
			do i = 1, nr
				res(i) = y(i) - sum(X(i, :)*beta)
			end do
			!$omp end do
			!$omp end parallel

			dtheta = -1*dble(nr)/theta + sum(res(:)**2)/theta**3
			do i = 1, nc
				dbeta(i) = sum(X(:, i)*2*res)/theta**2/2
			end do
			g(1) = dtheta
			g(2:nc + 1) = dbeta
		end subroutine
end module

ファイル名を llf_norm.f90 として保存します。

数値微分のコード

一階微分のコードを書いて検算をするのは手間なので、こちらの数値微分のコードを使う予定ですが、上の解析的な微分のコードを使う方が1.5倍から2倍ほど速かったです。

subroutine diff_llf(llf, nr, nc, X, y, p, g)
	implicit none
	interface
		double precision function llf(nr, nc, X, y, p)
			implicit none
			integer, intent(in) :: nr, nc
			double precision, dimension(nr, nc), intent(in) :: X
			double precision, dimension(nr), intent(in) :: y
			double precision, dimension(nc + 1), intent(in) :: p
		end function
    end interface
	integer, intent(in) :: nr, nc
	double precision, dimension(nr, nc), intent(in) :: X
	double precision, dimension(nr), intent(in) :: y
	double precision, dimension(nc + 1), intent(in) :: p
	double precision, dimension(nc + 1), intent(out) :: g
	double precision, dimension(nc + 1) :: q 
	double precision :: h = 1d-6, v_h, v_l
	integer :: i

	q(:) = p(:) ! 入力データはいじらない
	do i = 1, nc + 1
		q(i) = p(i) + h
		v_h = llf(nr, nc, X, y, q)
		q(i) = p(i) - h
		v_l = llf(nr, nc, X, y, q)
		g(i) = (v_h - v_l)/(2*h)
		q(i) = p(i) ! 戻す
	end do

end subroutine

ファイル名を diff_llf.f90 として保存します。

Rから呼び出すFortranのプロシージャ

Fortranの関数やプロシージャを何でも呼べるわけではないので、Rから呼び出すFortranのプロシージャを用意します。

subroutine llf(r, nr, nc, X, y, p)
	use norm_d, only:llf_norm
	implicit none
	double precision, intent(out) :: r
	integer, intent(in) :: nr, nc
	double precision, dimension(nr, nc), intent(in) :: X
	double precision, dimension(nr), intent(in) :: y
	double precision, dimension(nc + 1), intent(in) :: p

	r = llf_norm(nr, nc, X, y, p)

end subroutine

subroutine llfg(r, nr, nc, X, y, p)
	use norm_d, only:llf_norm
	implicit none
	integer, intent(in) :: nr, nc
	double precision, dimension(nr, nc), intent(in) :: X
	double precision, dimension(nr), intent(in) :: y
	double precision, dimension(nc + 1), intent(in) :: p
	double precision, dimension(nc + 1), intent(out) :: r

	call diff_llf(llf_norm, nr, nc, X, y, p, r)

end subroutine

subroutine llfag(r, nr, nc, X, y, p)
	use norm_d, only:llfg_norm 
	implicit none
	integer, intent(in) :: nr, nc
	double precision, dimension(nr, nc), intent(in) :: X
	double precision, dimension(nr), intent(in) :: y
	double precision, dimension(nc + 1), intent(in) :: p
	double precision, dimension(nc + 1), intent(out) :: r

	call llfg_norm(nr, nc, X, y, p, r)
end subroutine

ファイル名を llf.f90 として保存します。

FortranのコードをR用のDLLにする

コマンドラインとしたら、.soか.dllができます。なお、WindowsではRtoolsが要ります。

R CMD SHLIB llf.f90 diff_llf.f90 llf_norm.f90

# RからFortranのコードを呼び出す

ベンチマーク目的

# データセットを乱数からつくる
# 真のパラメーターは(1, 1, 1, -1)
df01 <- with(new.env(), {
	n <- 500000
	x <- runif(n)
	z <- runif(n)
	y <- 1 + x - z + rnorm(n)
	data.frame(y, x, z)
})

# 推定しやすいように展開
X <- model.matrix(y ~ x + z, data = df01)
nr <- nrow(X)
nc <- ncol(X)
p <- rep(1, nc + 1)

# FortranのDLL(llf.soもしくはllf.dll)を呼び出す
dll <- paste("llf", .Platform$dynlib.ext, sep="")
fs <- dyn.load(dll)

# 尤度関数、一階数値微分、一階解析微分
llf_f <- \(p) .Fortran("llf", double(1), nr, nc, X, df01$y, p)[[1]]
llf_g <- \(p) .Fortran("llfg", double(length(p)), nr, nc, X, df01$y, p)[[1]]
llf_ag <- \(p) .Fortran("llfag", double(length(p)), nr, nc, X, df01$y, p)[[1]]

# 解析による微分
print(system.time({
	r_ag <-  optim(p, \(p) -llf_f(p), \(p) -llf_ag(p), method = "BFGS")
}))

# 数値微分
print(system.time({
	r_ng <-  optim(p, \(p) -llf_f(p), \(p) -llf_g(p), method = "BFGS")
}))

# グラディエントを与えない(内部的に数値微分しているはず)
print(system.time({
	r_wg <-  optim(p, \(p) -llf_f(p), method = "BFGS")
}))

# DLLを開放(しないとFortranのソースコードをリコンパイルできない)
dyn.unload(dll)

速度比較に使ったRのコード

lmコマンドでOLSをすれば済むので、最尤法で正規分布を推定する人はほとんどいないと思いますが、計算手順の確認および比較のために用意しました。なお、ループなし一行で書ける尤度関数はそうはないはずなので、Rのパフォーマンス評価としてはRを美化しすぎている事例になります。

# 尤度関数
R_llf <- function(p){
	sum(dnorm(df01$y - X %*% p[2:(1 + nc)], sd = abs(p[1]), log = TRUE))
}

# 一階微分
R_llfg <- function(p){
	y <- df01$y
	nr <- nrow(X)
	nc <- ncol(X)
	theta <- p[1]
	beta <- p[-1]
	res <- y - X %*% beta
	dtheta <- -1*nr/theta + sum(res^2)/theta^3
	dbeta <- numeric(nc)
	for(i in 1:nc){
		dbeta[i] <- sum(X[, i]*2*res)/theta^2/2
	}
	c(dtheta, dbeta)
}

print(system.time({
	r_Ra <- optim(p, \(p) -R_llf(p), \(p) -R_llfg(p), method = "BFGS")
}))

*1:Rの線形代数は内部的にFortranで書かれた線形代数ライブラリ(BLAS)を呼んでいるため本質的には同等であるため、余計な呼び出し手続きが入るR/Fortranはオーバーヘッドの分、遅くなるのでしょう。

*2:最終段階まで飽きずに到達できるかは不明。