DockerとplumberでRの米上院議員投票解析モデルのAPIを作ってみた

# はじめに

こんにちは、事業会社で働いているデータサイエンティストです。

https://ainow.ai/dx-magazine/?article=article-988

今回の記事は

https://qiita.com/Gotoubun_taiwan/items/d450c05ffe7f7f818e99

の続きで、**ディリクレ過程レジーム埋め込みモデル**（**D**irichlet **P**rocess **R**egime **E**mbedding、**DPRE**）モデルの事後分布のAPIをDockerを利用して作成してみます。

技術の説明に入る前に、まずは政治学や経済学、法学、社会学の人にとって馴染みのないAPIとDocker（コンテナ化）の技術を利用する必要性から説明します。

この記事で利用するコードはgithubで公開しました。

https://github.com/wutungwen/dpre_plumber_api/tree/develop

また、ディレクトリ構成はこちらです：

```r
> fs::dir_tree()
.
├── README.md
├── docker
│   ├── Dockerfile.plumber
│   └── necessary_packages.R
├── docker-compose.yml
├── dpre_plumber_api.Rproj
├── estimation
│   ├── estimation.R
│   ├── senate_dpre
│   └── senate_dpre.stan
├── parameters
│   ├── cos_sim_dpre_df.obj
│   └── eta_dpre_df.obj
└── plumber
    ├── plumber.R
    └── run_plumber.R
```

# API化の必要性

最近の計量社会科学、特にベイズ機械学習関連のモデルでは、学術的に意義のあるQOI（Quantities of Interest）を大量に抽出してくれます。例えば、経済学の場合は大量の商品のスルツキー行列、政治学の場合は私が今まで書いた記事で提案したような、大量のイデオロギー比較などがあります。

大量のQOIの抽出自体は（偽陽性を避けるために事前分布の設定や正則化などの工夫が必要ですが）嬉しいことですが、巨大な結果ファイルの整形は面倒です。同じ言語だったらまだ良いですが、例えばPythonのpyroで構築した国際法学モデルのパラメータを普段Rしか使わない人が抽出・整形するのは困難で、途中でモデル開発者が想定したロジックの再現が失敗する可能性もあります。

そこで、APIを利用すれば、モデル開発者が定義した方式でリクエストを飛ばせば、誰がどんな言語を利用しても、モデルに同じ指示をすることができ、そして同じ結果が得られます。

少し話が脱線しますが、ビジネス側の方にとって、LookMLでビジネスロジックを統一することに似ている、と説明した方がわかりやすいかもしれません。

# Dockerの必要性

Dockerはすごく難しいですが、Dockerのおかげで、プログラムが動作する環境（ハードウェア、通信系は除く）の同一性を完全に担保でき、高い移植性を実現できます。要するに、クラウドなど、より多くの人がアクセスでき、かつ計算資源の拡充が容易なところに、自分が今まで手元のPCでやっていたことを簡単に再現できます。

私もDockerの初心者で、ChatGPTなしではDockerが書けないですが、Dockerはただの*処理指示書*だと理解してください（情報工学の専門家の皆さん許してください）。

台湾人なので、外国人が長期滞在で日本に入国した後やらないといけないことをDocker風に表現すると

```docker
RUN 空港で在留カードをもらう
RUN 役所で住民登録する
RUN 携帯の契約をする
RUN 銀行口座を作る
```

になります。何をするかの指示を書くだけです。実は割と単純で怖くないです。

# API定義

さて、APIの定義ですが、今回はコサイン類似度と$\eta$の事後分布を抽出するAPIを作ります。両方とも上院議員名を入力とし、コサイン類似度の方はさらに上位何件を持ってくるかを指定する数字も受け取ります。

# plumber API構築

では早速APIを構築しましょう！

plumberの書き方の詳細はこちらの記事で確認してください。

https://qiita.com/Gotoubun_taiwan/items/59ab5a6d257dbb56d0ff

```r: plumber.R
library(plumber)

#* @apiTitle DPRE API
#* @apiDescription DPREモデルの事後分布からQOIを抽出するAPI 

#* コサイン類似度を抽出する
#* @param senator 対象の上院議員
#* @param top_num 抽出データ数
#* @get /cos_sim
function(senator, top_num) {
  cos_sim_dpre_df |>
    dplyr::filter(.data$senator_1 == .env$senator) |>
    dplyr::select(senator_2, mean, q5, q95) |>
    dplyr::arrange(-mean) |>
    head(as.integer(top_num))
}

#* ETAの事後分布を抽出する
#* @param senator 対象の上院議員
#* @get /eta
function(senator) {
  eta_dpre_df |>
    dplyr::filter(.data$senator == .env$senator) |>
    dplyr::select(senator, latent_group_id, mean, q5, q95)
}
```

割と単純な処理なんですが、今回の記事はplumberとDockerの利用方法がメインなので、裏のRの処理の難易度はあえて抑えています。

実行時は、こちらのRのスクリプトで必要なデータを読み込ませた後にAPIを起動させる作業手続きを指定します：

```r: run_plumber.R
library(plumber)

cos_sim_dpre_df <- readRDS("parameters/cos_sim_dpre_df.obj")

eta_dpre_df <- readRDS("parameters/eta_dpre_df.obj")

plumb("plumber/plumber.R")$
  run(host = "0.0.0.0", port = 8000)
```

# Dockerfile構築

次はDockerfileを構築します。

これはChatGPTと壁打ちしながら書きました。生成AIに感謝です！

Dockerfileは要するに、仮想PC内に指示を出す手順書だけです。

「ここのファイルをここに持ってきてー」「フォルダ作ってー」「これをダウンロードしておいてー」だけです。

実際はこんな感じです：

```docker
FROM rstudio/plumber

WORKDIR /api

RUN mkdir parameters
RUN mkdir plumber

COPY parameters/* parameters/
COPY plumber/* plumber/

RUN apt-get update && apt-get install -y \
    libssl-dev \
    libcurl4-openssl-dev

# Install R packages
RUN install2.r dplyr

ENTRYPOINT ["Rscript", "plumber/run_plumber.R"]
```

何をしているのかというと、

- FROM：イメージ（他の人が作ってくれた便利な箱みたいなもの）を指定する
- WORKDIR：作業ディレクトリを指定する
- RUN：コマンドの実行。ほぼlinuxコマンドの前にRUNをつけるだけです
- COPY：必要なファイルをCOPY [本物の手元のPC内の場所] [仮想PC内の場所]で指定します
- ENTRYPOINT：仮想PC起動時に行われる処理の指示

です。

日本語に訳すと

```
rstudio/plumberからイメージを持ってきて

/apiという場所で作業するよ

parametersフォルダを作って
plumberフォルダを作って

手元のPCのparametersフォルダ配下の前ファイルをコピーして仮想PCのparametersフォルダに入れて
手元のPCのplumberフォルダ配下の前ファイルをコピーして仮想PCのplumberフォルダに入れて

linux側で必要なパッケージを入れて

dplyrをRにインストールして

起動されたら、仮想PCのplumber/run_plumber.RファイルをRで実行して
```

になります。

# docker-compose.yml構築

docker-compose.ymlとは、簡単にいうとDockerの細かい環境設定を指示するファイルです。

```yaml
version: '3.8'

services:
  dpre_api:
    build:
      context: .
      dockerfile: docker/Dockerfile.plumber  # Specify the custom Dockerfile name
    container_name: dpre_api  # Specify the custom container name
    ports:
      - '8000:8000'
```

これも基本的にはChatGPTと壁打ちしながらやれば書けるようになります。

ちなみに、ChatGPTにコードの質問をする際は、必ず理由を聞きましょう。ハルシネーションの心配ももちろんありますが、理由を知ることで、コードの理解が深まり、いずれはChatGPTに依存しない、もしくは他人に教えられるほどの知識を得ることができます。

# 結果確認

まず、Rstudioのterminalで、

```sh
docker-compose build
```

を入れて実行します。

そしたら仮想PC内で指定された処理が実行されます。

実行が終わったら

```sh
docker-compose up
```

を実行して、このような結果が出てきます。

```sh
[+] Running 1/0
 ✔ Container dpre_api  Created                                                                       0.0s 
Attaching to dpre_api
dpre_api  | Running plumber API at http://0.0.0.0:8000
dpre_api  | Running swagger Docs at http://127.0.0.1:8000/__docs__/
```

これで手元のPCのchromeやR、Pythonなどからアクセスできるようになりました。

## chromeでアクセスする

実際にhttp://127.0.0.1:8000/__docs__/ を実行したら

![Screen Shot 2024-02-23 at 17.58.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2257087/16b3c4c8-8e72-3025-d406-cf0f8a0d544b.png)

の画面が出てきます。

これはPythonのFastAPIなども利用しているAPI開発者向けツールSwaggerです。

念の為の強調なんですが、今何が起きているのかというと、私のPCは実は今仮想PCという別のPCとやりとりしています。たまたま（？）両方とも私のPC内に存在しているだけです。なので、普段直接plumber::pr_run()を実行するときの状況とは異なります。

ここで、実際にオバマさんとコサイン類似度が近い議員を抽出してみましょう：

![Screen Shot 2024-02-23 at 18.00.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2257087/b527dedc-63c1-90db-e565-dc7ac77495b0.png)

念の為手元のRでも（そう、この二つのRは一応別PCに存在している扱いになっています）実行してみよう：

```r
> cos_sim_dpre_df |> dplyr::filter(senator_1 == "OBAMA (D IL)") |> dplyr::select(senator_2, mean, q5, q95) |>
     dplyr::arrange(-mean)
# A tibble: 102 × 4
   senator_2        mean    q5   q95
   <chr>           <dbl> <dbl> <dbl>
 1 OBAMA (D IL)    1     1     1    
 2 MIKULSKI (D MD) 0.993 0.987 0.997
 3 DODD (D CT)     0.992 0.985 0.997
 4 MENENDEZ (D NJ) 0.991 0.984 0.997
 5 REED (D RI)     0.990 0.983 0.996
 6 LEVIN (D MI)    0.989 0.981 0.995
 7 DURBIN (D IL)   0.989 0.981 0.996
 8 KERRY (D MA)    0.989 0.980 0.995
 9 CANTWELL (D WA) 0.989 0.981 0.995
10 SARBANES (D MD) 0.988 0.979 0.995
# ℹ 92 more rows
```

一致していますね！

$\eta$の方も確認すると、APIでは

![Screen Shot 2024-02-23 at 18.06.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2257087/c9ed408d-b6a6-7e81-e70c-f9415672051b.png)

の結果を返却していますが、R側は

```r
> eta_dpre_df |> dplyr::filter(senator == "NELSON (D NE)")
# A tibble: 5 × 11
  variable     mean     median     sd       mad      q5     q95 senator_id latent_group_id senator party_id
  <chr>       <dbl>      <dbl>  <dbl>     <dbl>   <dbl>   <dbl>      <int>           <int> <chr>      <dbl>
1 eta[77,1] 0.904   0.926      0.0942 0.0762    7.34e-1 0.996           77               1 NELSON…        2
2 eta[77,2] 0.0843  0.0632     0.0776 0.0724    1.39e-3 0.237           77               2 NELSON…        2
3 eta[77,3] 0.00852 0.000258   0.0538 0.000378  5.42e-7 0.0247          77               3 NELSON…        2
4 eta[77,4] 0.00174 0.0000202  0.0148 0.0000298 5.89e-9 0.00452         77               4 NELSON…        2
5 eta[77,5] 0.00148 0.00000853 0.0234 0.0000126 3.26e-9 0.00267         77               5 NELSON…        2
```

結果は一緒ですね！

## RからAPIを叩く

では、RからAPIを叩きましょう。うるさいかもしれませんが、今の状況を繰り返し説明すると、私の手元のPCのRが、仮想PCという別のPCに存在しているRのAPIに対して、リクエストを出します。なので、これが成功したら、この仮想PCの構築方法（Dockerfileなど）をクラウドにアップロードすれば、リンクを変えるだけで世界中の人からのアクセスを受け付けることが可能になります。

早速確認しましょう。APIを叩くためのURLはSwaggerのRequest URLから確認できます：

```r
> httr::GET("http://127.0.0.1:8000/cos_sim?senator=OBAMA%20%28D%20IL%29&top_num=3") |> httr::content()
[[1]]
[[1]]$senator_2
[1] "OBAMA (D IL)"

[[1]]$mean
[1] 1

[[1]]$q5
[1] 1

[[1]]$q95
[1] 1


[[2]]
[[2]]$senator_2
[1] "MIKULSKI (D MD)"

[[2]]$mean
[1] 0.9928

[[2]]$q5
[1] 0.9868

[[2]]$q95
[1] 0.9971


[[3]]
[[3]]$senator_2
[1] "DODD (D CT)"

[[3]]$mean
[1] 0.992

[[3]]$q5
[1] 0.9853

[[3]]$q95
[1] 0.9969
```

問題なく結果を再現しました！

# 結論

このように、Dockerとplumberを組み合わせれば、あなたの研究結果をインターネットの世界の共通言語で幅広いユーザーに提供できます。これで、モデルから得られた示唆がより多くの人に理解され、より大きいインパクトを発揮できると思います。

ぜひDockerとplumberを活用してください！