11  パイプライン

{targets} は, 研究のワークフローを構築する R のパッケージです. 最大の特徴は, データ, 関数, 結果を R のオブジェクトとしてその依存関係を管理し, 上流のオブジェクトが変更されたときに, それに依存する下流のオブジェクトを自動的に再計算してくれることです. これにより, 再現性を保ち続けたまま研究を進めることができます.

さらに Quarto と組み合わせることで, 試行錯誤のノート, 発表スライド, 論文の執筆まで, 研究のワークフロー全体を一つのプロジェクトの中で管理できます. この章では, まず小さなパイプラインを組んで {targets} の基本を確認します. 次に, 短い論文を書くパイプラインを LaTeX と Quarto のそれぞれで組み, 最後にそれを試行錯誤のノートや発表スライドまで広げた研究のワークフローを解説します. 研究は直線的に進むものではなく, 試行錯誤を繰り返しながら道を見つけていくものです. そのため, このワークフローでは, 論文を書き始める前の試行錯誤の段階に, ある程度の自由度を持たせています.

最後に説明する研究のワークフローは, そのまま使えるテンプレート kazuyanagimoto/template-research にまとめてあります. GitHub の「Use this template」から自分のリポジトリを作り, 手元で動かしながら読むと理解しやすいはずです.

11.1 {targets} の基本

{targets} の基本を学ぶには公式のチュートリアルが良いですが, 実用上は {targets} を拡張した {tarchetypes} の文法を使うことが多いです. そのため, ここでは {tarchetypes} の文法に基づいて, 小さなパイプラインを実際に組みながら最低限の使い方を解説します. この {targets} から {tarchetypes} への移行に関しては, このチュートリアルが参考になりました.

なぜパイプラインを使うのか

分析を 01_clean.R, 02_estimate.R, 03_figure.R のようなスクリプトに分け, 番号順に実行するやり方はよく使われます. 順に実行し直せば出力はすべて最新になりますが, このやり方は変更に弱いです. 例えば 01_clean.R を直すと, その下流の出力はすべて古くなります (図 11.1). しかし, どの出力が古くなったかは, ファイルを見ても分かりません. 人は自分の記憶に, AI は会話の中に残っている情報に頼って, どのスクリプトを実行し直すかを判断するしかありません.

図 11.1: スクリプトを番号順に実行するやり方

依存関係をコードとして書いておけば, 古くなった範囲は機械的に判定できます. それを引き受けるのが {targets} です. 例えば tar_outdated() は, 古くなったターゲットを一覧にします.

基本の3つの要素

{targets} の哲学は, 研究のワークフローを三つの要素に分けて考えることです. それは, ファイル, オブジェクト, 関数です (図 11.2).

  • ファイル: tar_file() で定義される, ファイルのパス名を持つオブジェクト. パスに加えて, ファイルの中身の指紋 (ハッシュ) も記録しているため, パスが変わらなくても中身が変われば, 依存するターゲットが再計算される
  • オブジェクト: 変数やデータフレームなどの R のオブジェクト
  • 関数: R の関数. ただし, インプットには依存する全てのオブジェクトを指定する必要があり, アウトプットがファイルまたはオブジェクトである必要がある. これにより, 依存関係を明示的にすることができる

イメージとしては, ファイルから始まり, それを読み込んでオブジェクトを作成し, そのオブジェクトを関数に通して新しいオブジェクトを作成するという流れです. 以下では, 実際に手を動かしてこの流れを作ってみます.

図 11.2: パイプラインを構成する3つの要素

パイプライン

ここでは実際にパイプラインを作りながら {targets} の基本を確認します. まず次のようなファイル構成を作ります. ここではデータとして penguins データを使うため, write.csv(penguins, "data/penguins.csv", row.names = FALSE) を R のコンソールで一度だけ実行して, data/penguins.csv を作っておいてください.

.
├── _targets.R
├── data/
│   └── penguins.csv
└── R/
    └── functions.R

次に, _targets.R にパイプラインを定義していきます.

_targets.R
library(targets)
library(tarchetypes)
suppressPackageStartupMessages(library(dplyr))

tar_source() # Source all R files in R/ directory

tar_plan(
  # Write pipeline here
)
  • targets, tarchetypes はパイプラインを定義するためのパッケージです. dplyr はデータの操作に使います
  • tar_source() は, R/ ディレクトリにある R ファイルをすべて読み込みます. ここでは R/functions.R に, パイプラインで使う関数を定義しておきます
  • tar_plan() の中に, パイプラインのターゲットを定義していきます. ここではまだ空です

ここから, tar_plan() の中にパイプラインを定義していきます.

ファイルを定義する

tar_file() を使って, ファイルを定義します. これは, ファイルのパス名を持つオブジェクトです. 例えば, data/penguins.csv を定義するには次のようにします.

_targets.R
tar_plan(
  tar_file(penguins_file, "data/penguins.csv")
)

この時, penguins_file は, ファイルのパス名を持つオブジェクトです. さらに, このファイルを読み込んでオブジェクトを作るには, readr::read_csv() を使います.

_targets.R
tar_plan(
  tar_file(penguins_file, "data/penguins.csv"),
  penguins = readr::read_csv(penguins_file, show_col_types = FALSE)
)

今, パイプラインの状態を確認します. まだ計画を立てている段階なので, コードは実行されていません. Rコンソールで次のコマンドを実行すると, 依存関係の図がブラウザに表示されます. なお初めの1回は必要なパッケージのインストールの同意を求められる場合があります.

targets::tar_visnetwork()
図 11.3

ノードの色はターゲットの状態を表します. 水色 (Outdated) は, まだ実行されていないか, 上流が変わって古くなったターゲットです. ここでは2つとも水色です. これを実行するには次のコマンドを実行します.

targets::tar_make()
+ penguins_file dispatched
✔ penguins_file completed [0ms, 17.28 kB]
+ penguins dispatched
✔ penguins completed [76ms, 3.16 kB]
✔ ended pipeline [138ms, 2 completed, 0 skipped]

dispatched はそのターゲットの計算を始めたこと, completed は計算が終わって結果を保存したことを表します. 角括弧の中は, かかった時間と保存されたオブジェクトのサイズです. 最後の行はパイプライン全体の要約で, ここでは2つのターゲットを計算し, 飛ばしたものは1つも無かったことが分かります. 計算中に tar_visnetwork() を開くと, 計算を始めたターゲット (dispatched) はオレンジで表示されます.

計算した結果は, プロジェクトのルートにできる _targets/ というディレクトリ (ストア) に保存されます. 結果を取り出すときは, 計算し直すのではなく, ストアから targets::tar_read(penguins) で読み出します. 状態を確認すると, ノードが最新 (Up to date) を表す緑に変わっています.

targets::tar_visnetwork()
図 11.4

なお, 実務的にはこの二つのパイプラインを統合する一つのコマンドにまとめます.

_targets.R
tar_plan(
  tar_file_read(
    penguins,
    "data/penguins.csv",
    readr::read_csv(!!.x, show_col_types = FALSE)
  )
)
targets::tar_make()
targets::tar_visnetwork()
図 11.5

tar_file_read() は, tar_file() と何らかの読み込み関数を組み合わせたものになります. 指定したオブジェクト名 (penguins) が読み込まれたオブジェクトになり, それに _file がついたものがファイルのターゲットになります.

関数で処理をする

penguins をクリーニングし, 種ごとの平均くちばし長を計算する関数を定義します. これらの関数は, R/functions.R に書きます.

R/functions.R
clean_penguins <- function(penguins) {
  penguins |>
    filter(!is.na(bill_len))
}

summarize_penguins <- function(penguins_cleaned) {
  penguins_cleaned |>
    summarize(bill_len = mean(bill_len), .by = species)
}
_targets.R
tar_plan(
  tar_file_read(
    penguins,
    "data/penguins.csv",
    readr::read_csv(!!.x, show_col_types = FALSE)
  ),
  penguins_cleaned = clean_penguins(penguins),
  penguins_summary = summarize_penguins(penguins_cleaned)
)
targets::tar_make()
targets::tar_visnetwork()
図 11.6

関数とオブジェクトの依存関係が図示されました. ここで, penguins_cleaned は penguins に依存し, penguins_summary は penguins_cleaned に依存しています. この矢印は, 誰も手で書いていません. {targets} が, ターゲットを作るコードに現れる名前 (ここでは関数の引数) から, 依存関係を自動で読み取っています. そのため, もし penguins が変更された場合は, それに依存する penguins_cleaned と penguins_summary が再計算されます.

再計算

パイプラインが力を発揮するのは, 関数やデータを変更した時です.

関数を変更

clean_penguins() に, 性別が欠損している個体を除く処理を追加します.

clean_penguins <- function(penguins) {
  penguins |>
    filter(!is.na(bill_len), !is.na(sex))
}

この時の状態を確認すると, 変更の影響を受けるターゲットだけが古くなっていることが分かります.

targets::tar_visnetwork()
図 11.7

古くなったターゲットは, tar_outdated() で名前の一覧としても確かめられます.

targets::tar_outdated()
[1] "penguins_cleaned" "penguins_summary"

パイプラインを実行すると, 変更の影響を受けるターゲットだけが再計算されます.

targets::tar_make()
+ penguins_cleaned dispatched
✔ penguins_cleaned completed [2ms, 3.03 kB]
+ penguins_summary dispatched
✔ penguins_summary completed [3ms, 202 B]
✔ ended pipeline [69ms, 2 completed, 2 skipped]
targets::tar_visnetwork()
図 11.8

データを変更

生データが更新された状況を再現するために, 2008年以降の観測だけを残した CSV で上書きします. これもパイプラインの外, R のコンソールから実行します.

write.csv(subset(penguins, year >= 2008), "data/penguins.csv", row.names = FALSE)

ファイルのパスは変わっていませんが, tar_file() はファイルの中身も記録しているので, 変更が検知されます.

targets::tar_visnetwork()
図 11.9

今度は penguins_file を起点に, その下流のターゲットがすべて古くなりました. 一方で clean_penguins() と summarize_penguins() は変更していないので, 関数のノードは緑 (Up to date) のままです. 関数を変更した 図 11.7 とちょうど裏返しの関係になっています.

targets::tar_make()
+ penguins_file dispatched
✔ penguins_file completed [0ms, 11.78 kB]
+ penguins dispatched
✔ penguins completed [75ms, 2.36 kB]
+ penguins_cleaned dispatched
✔ penguins_cleaned completed [3ms, 2.31 kB]
+ penguins_summary dispatched
✔ penguins_summary completed [3ms, 199 B]
✔ ended pipeline [152ms, 4 completed, 0 skipped]
targets::tar_visnetwork()
図 11.10

覚えておくべき4つのコマンド

ここまでに使ったコマンドは, 次の4つにまとまります (図 11.11).

  • tar_visnetwork(): 依存関係のグラフと, 各ターゲットの状態を見る
  • tar_outdated(): 古くなったターゲットを一覧にする
  • tar_make(): 古くなったターゲットだけを, 上流から順に計算し直す
  • tar_read() と tar_load(): ストアに保存した結果を取り出す
図 11.11: 覚えておくべき4つのコマンド

11.2 LaTeX + {targets} のワークフロー

次はより実践的な例を考えてみます. データのクリーニングから, 図表の作成, 論文のコンパイルまでを1本のパイプラインにまとめます. このようなパイプラインを組むことができたら, かなり再現性の高い Replication Package になりますし, パイプラインから分析の意図を読み取ることが可能になります.

ここでは, fixest に同梱されている疑似パネルデータ base_did を使って, 記述統計とDiDの分析を載せた短い論文を執筆することにします. 以下では要所のコードだけを示しますが, 完成したパイプラインを1つのフォルダにまとめて配布しているので, 先にダウンロードして手元で動かしながら読み進めてください. targets-latex.zip

展開してできる targets-latex/ を作業ディレクトリにして targets::tar_make() を実行すると, 生データから manuscript/main.pdf までが一度に作られます. 必要な R パッケージと動かし方は, フォルダの中の README.md にまとめてあります.

フォルダ構成

.
├── README.md
├── _targets.R
├── R/
│   ├── utils.R
│   ├── tar_data.R
│   ├── tar_analysis.R
│   └── tar_manuscript.R
├── data/
│   └── base_did.csv
├── output/
│   ├── img/
│   └── table/
└── manuscript/
    ├── main.tex
    └── references.bib
  • R/: tar_data, tar_analysis, tar_manuscript の3つのサブプランを置き, データの準備, 図表の作成, 原稿のコンパイルに役割を分けます.
  • data/: 元データを格納するフォルダです. ここに格納されたデータは, パイプラインから読み込まれて処理されます. 基本的には Git の管理対象から外します.
  • output/: パイプラインが書き出す図表の置き場です

_targets.R は3つのサブプランを並べるだけです.

_targets.R
library(targets)
library(tarchetypes)

tar_option_set(
  packages = c(
    "dplyr", "ggplot2", "fixest", "modelsummary", "tinytable", "fs"
  )
)

tar_source()

tar_plan(
  tar_data,
  tar_analysis,
  tar_manuscript
)

here_rel() でパスを書く

R/utils.R には, ファイルのパスを作る here_rel() を置いてあります. 以降のコードでファイルを指すときは, すべてこの関数を使います.

R/utils.R
here_rel <- function(...) {
  fs::path_rel(here::here(...))
}

here::here() は, プロジェクトのルートディレクトリを基準にしたパスを作る関数です. これを使うことで, どのディレクトリから実行してもパスを書き換える必要がなくなります.

ただし, here::here() が返すのは絶対パス (/Users/yourname/...) です. これをそのまま tar_file() に渡すと, ストアのメタデータに自分のマシンのパスが記録されます. 手元で動かしている間は問題になりませんが, プロジェクトをストアごと共有したり別の場所に移したりすると, 記録されたパスがその環境には存在しないため, ファイルのターゲットは古いと判定され, 登録し直しになります. tar_read() が返す値も, 記録された時点の絶対パスのままです. ルートからの相対パス (data/base_did.csv) で記録しておけば, どちらも起こりません. この部分は Andrew Heiss さんのコードを参考にしています.

データクリーニング

R/tar_data.R は基本の節と同じ形です. base_did を CSV として data/ に置いてあるものとし, tar_file_read() で登録して読み込みます.

R/tar_data.R
tar_data <- tar_plan(
  tar_file_read(
    did_raw,
    here_rel("data", "base_did.csv"),
    readr::read_csv(!!.x, show_col_types = FALSE)
  ),
  did = clean_did(did_raw)
)

clean_did <- function(did_raw) {
  period_treated <- min(did_raw$period[did_raw$post == 1])

  did_raw |>
    mutate(
      group = if_else(treat == 1, "Treated", "Control"),
      rel_period = period - period_treated
    )
}

図表をファイルに書き出す

R/tar_analysis.R が, 次の節の Quarto 版 (セクション 11.3) と一番違うところです. 図と表のターゲットが tar_file() になり, 関数は保存先のパスを返します.

R/tar_analysis.R
tar_analysis <- tar_plan(
  tab_balance = summarize_did(did),
  tar_file(
    tab_balance_file,
    tabular_balance(
      tab_balance,
      here_rel("output", "table", "tab_balance.tex")
    )
  ),
  tar_file(
    fig_trends_file,
    plot_trends(did, here_rel("output", "img", "fig_trends.pdf"))
  ),
  models_did = estimate_did(did),
  tar_file(
    tab_did_file,
    tabular_did(models_did, here_rel("output", "table", "tab_did.tex"))
  ),
  model_event = estimate_event(did),
  tar_file(
    fig_event_file,
    plot_event(model_event, here_rel("output", "img", "fig_event.pdf"))
  )
)

図を描く関数は, ggsave() で保存したあとにパスを返します. 返り値がパスであることが tar_file() の要件です.

R/tar_analysis.R
plot_trends <- function(did, path) {
  cutoff <- min(did$period[did$post == 1]) - 0.5

  p <- did |>
    summarize(mean_y = mean(y), .by = c(period, group)) |>
    ggplot(aes(x = period, y = mean_y, color = group,
               linetype = group, shape = group)) +
    geom_vline(xintercept = cutoff, linetype = "dotted", color = "grey40") +
    geom_line() +
    geom_point(size = 2) +
    scale_x_continuous(breaks = seq_len(max(did$period))) +
    labs(x = "Period", y = "Mean outcome",
         color = NULL, linetype = NULL, shape = NULL) +
    theme_classic(base_size = 11) +
    theme(legend.position = "inside",
          legend.position.inside = c(0.15, 0.85))

  ggsave(path, p, width = 6, height = 3.5)
  path
}

回帰表も同じ形です. modelsummary() の結果を tinytable のオブジェクトとして受け取り, LaTeX のファイルに書き出します.

R/tar_analysis.R
tabular_did <- function(models_did, path) {
  tab <- modelsummary(
    models_did,
    output = "tinytable",
    escape = FALSE,
    coef_map = c(
      "post:treat" = "Post $\\times$ Treated",
      "post::1:treat" = "Post $\\times$ Treated",
      "post" = "Post",
      "treat" = "Treated",
      "x1" = "$x_1$"
    ),
    gof_map = c("nobs", "r.squared", "FE: id", "FE: period"),
    stars = c("*" = 0.1, "**" = 0.05, "***" = 0.01)
  )

  tab |>
    theme_latex(environment_table = FALSE) |>
    save_tt(path, overwrite = TRUE)
  path
}

theme_latex(environment_table = FALSE) を付けて, 表を囲む table 環境を出さないのがポイントです. キャプション, ラベル, 配置は原稿の側が決めるべきものなので, パイプラインが書き出すのは表の中身だけにしておきます. 図と同じく, save_tt() で保存したあとにパスを返します.

原稿では表を描画するために次のパッケージが必要です.

manuscript/main.tex
\usepackage[T1]{fontenc}
\usepackage{lmodern}
\usepackage{xcolor}
\usepackage{tabularray}

本文では, 表の置き場所を table 環境で用意して読み込みます.

manuscript/main.tex
\begin{table}[ht]
\centering
\caption{Difference-in-differences estimates}
\label{tab:did}
\input{../output/table/tab_did.tex}
\end{table}

\begin{figure}[ht]
\centering
\includegraphics[width=0.8\textwidth]{../output/img/fig_event.pdf}
\caption{Event-study estimates}
\label{fig:event}
\end{figure}

論文をコンパイルする

R/tar_manuscript.R で main.tex と references.bib をファイルとして登録し, tinytex でコンパイルします.

R/tar_manuscript.R
tar_manuscript <- tar_plan(
  tar_file(main_tex_file, here_rel("manuscript", "main.tex")),
  tar_file(bib_file, here_rel("manuscript", "references.bib")),
  tar_file(
    manuscript_pdf_file,
    compile_manuscript(
      main_tex_file,
      bib_file,
      tab_balance_file,
      tab_did_file,
      fig_trends_file,
      fig_event_file
    )
  )
)

compile_manuscript <- function(main_tex_file, ...) {

  withr::with_dir(
    fs::path_dir(main_tex_file),
    tinytex::pdflatex(fs::path_file(main_tex_file))
  )
  fs::path_ext_set(main_tex_file, "pdf")
}

ポイントは2つあります. 一つは, 論文の中で参照している全てのファイルを, compile_manuscript() の引数として渡すことです. これのおかげで, 図表を修正した場合, パイプラインが自動で論文のコンパイルまでやってくれます. もう一つは, withr::with_dir() を使って作業ディレクトリを main.tex のあるディレクトリに移している点です. これを行わないと, main.tex からの相対パスが正しく解釈されず, コンパイルに失敗します.

パイプラインを実行する

tar_make() を実行すると, データの読み込みから図表の書き出しを経て, PDF のコンパイルまでが一度に走ります.

targets::tar_make()

できあがった PDF が 図 11.12 です.

図 11.12

パイプラインを図解すると 図 11.13 のようになります. 四角いノードが R のオブジェクトを持つターゲット, 角の丸いノードが tar_file() で定義したファイルのターゲット, 黒い旗形のノードが関数で, 3つのサブプランを囲みで示しています. 関数からの矢印は, その関数を使っているターゲットに向かいます. R/utils.R の here_rel() はどのサブプランにも属さないので, 囲みの外に置いています.

図 11.13: LaTeX パイプラインの依存関係

11.3 Quarto + {targets} のワークフロー

同じ論文を, 今度は Quarto で書きます. Quarto で書く利点は, 原稿がパイプラインの結果を直接読めることです. パイプラインは集計表や推定結果を R のオブジェクトとして作るところまでを受け持ち, 原稿がそれを tar_load() で読み込んで, 図表を自分で作ります. さらに {tarchetypes} の tar_quarto() を使うと, 原稿がどのオブジェクトを読み込んでいるかを {targets} が自分で調べて, 依存関係に加えてくれます. 完成したパイプラインを配布しているので, ダウンロードして手元で動かしながら読み進めてください. targets-quarto.zip

展開してできる targets-quarto/ を作業ディレクトリにして targets::tar_make() を実行すると, 生データから manuscript/main.pdf までが一度に作られます. PDF は Quarto に同梱されている Typst で組むので, LaTeX のインストールは要りません. 必要な R パッケージと動かし方は, フォルダの中の README.md にまとめてあります.

フォルダ構成

.
├── README.md
├── _targets.R
├── R/
│   ├── utils.R
│   ├── tar_data.R
│   ├── tar_analysis.R
│   └── tar_manuscript.R
├── data/
│   └── base_did.csv
└── manuscript/
    ├── main.qmd
    └── references.bib

図表のファイルを置くフォルダはありません. 図表は, 原稿をレンダリングするたびにその中で作られるからです. R/utils.R と R/tar_data.R は セクション 11.2 と同じなので, ここでは省略します.

_targets.R は3つのサブプランを並べるだけです. パイプラインが読み込むパッケージは, データの処理と推定に使う dplyr と fixest だけで, 図表を作る ggplot2, modelsummary, tinytable は原稿の中で読み込みます.

_targets.R
library(targets)
library(tarchetypes)

tar_option_set(
  packages = c("dplyr", "fixest")
)

tar_source()

tar_plan(
  tar_data,
  tar_analysis,
  tar_manuscript
)

パイプラインはオブジェクトで止める

R/tar_analysis.R は, 原稿が使う4つのオブジェクトを作ります.

R/tar_analysis.R
tar_analysis <- tar_plan(
  tab_balance = summarize_did(did),
  trends = summarize_trends(did),
  models_did = estimate_did(did),
  model_event = estimate_event(did)
)

summarize_trends <- function(did) {
  did |>
    summarize(mean_y = mean(y), .by = c(period, post, group))
}

summarize_did() は群と処置の前後ごとの平均を, estimate_did() は差の差の回帰を4本, estimate_event() はイベントスタディを推定する関数です (中身は セクション 11.2 と同じです). どのターゲットも図表ではなく, データフレームや推定結果のオブジェクトです. たとえば trends は群と期間ごとの平均で, これを図にするのは原稿の仕事です. 計算はパイプラインに, 描画は原稿に分けておくと, 図の見た目を変えても計算はやり直されません.

図 11.14 は, LaTeX と Quarto で図表を作る場所の違いです. LaTeX の原稿は R のオブジェクトを読めないので, 図表をファイルに書き出すところまでがパイプラインの仕事でした (セクション 11.2). Quarto ではパイプラインはオブジェクトで止まり, 図表は原稿をレンダリングするときに, その中で作られます.

図 11.14: LaTeX と Quarto で図表を作る場所

原稿の中で図表を作る

manuscript/main.qmd の冒頭のチャンクで, パイプラインの結果を読み込みます.

manuscript/main.qmd
library(dplyr)
library(ggplot2)
library(fixest)
library(modelsummary)
library(tinytable)

targets::tar_load(
  c(tab_balance, trends, models_did, model_event),
  store = here::here("_targets")
)

theme_set(theme_classic(base_size = 11))

tar_load() は, 指定したターゲットをストアから取り出し, 同じ名前のオブジェクトとして読み込みます. 原稿のコードは, tar_make() から実行されるときはプロジェクトのルートで, 手でプレビューするときは manuscript/ の中で実行されます. どちらでも同じストアを指せるように, store にはプロジェクトのルートにある _targets/ を here::here() で指定しています. 推定結果は fixest のオブジェクトなので, それを扱うために fixest も読み込みます. 最後の theme_set() は, 原稿のすべての図に共通するテーマの指定です.

図は, 読み込んだオブジェクトからチャンクの中で描きます.

manuscript/main.qmd
#| label: fig-trends
#| fig-cap: "Mean outcome by period"
cutoff <- min(trends$period[trends$post == 1]) - 0.5

ggplot(trends, aes(x = period, y = mean_y, color = group,
                   linetype = group, shape = group)) +
  geom_vline(xintercept = cutoff, linetype = "dotted", color = "grey40") +
  geom_line() +
  geom_point(size = 2) +
  scale_x_continuous(breaks = seq_len(max(trends$period))) +
  labs(x = "Period", y = "Mean outcome",
       color = NULL, linetype = NULL, shape = NULL) +
  theme(legend.position = "inside",
        legend.position.inside = c(0.15, 0.85))

キャプションとラベルはチャンクオプションの fig-cap と label で付け, 本文からは @fig-trends で参照します.

回帰表も同じように, チャンクの中で modelsummary() を呼びます.

manuscript/main.qmd
#| label: tbl-did
#| tbl-cap: "Difference-in-differences estimates"
modelsummary(
  models_did,
  output = "tinytable",
  escape = FALSE,
  coef_map = c(
    "post:treat" = "Post $times$ Treated",
    "post::1:treat" = "Post $times$ Treated",
    "post" = "Post",
    "treat" = "Treated",
    "x1" = "$x_1$"
  ),
  gof_map = c("nobs", "r.squared", "FE: id", "FE: period"),
  stars = c("*" = 0.1, "**" = 0.05, "***" = 0.01)
) |>
  format_tt(j = 2:5, escape = TRUE) |>
  format_tt(i = "notes", escape = TRUE)

最後の2行はエスケープです. Typst では * が太字の記号なので, 星印をそのまま渡すと表が壊れます. かといって表全体をエスケープすると, 1列目の係数名に入れた数式まで文字として表示されてしまいます. そこで escape = FALSE としたうえで, 星印の入る列 (j = 2:5) と注 (i = "notes") だけをエスケープしています. なお, セルの中の数式は出力形式の記法で書く必要があるので, ここでは Typst の記法 ($times$) を使っています (回帰分析 の章を参照).

本文中の数値も, 読み込んだオブジェクトからインラインコードで埋め込めます. 例えば推定値 est を本文に書くときは, 数字を手で書き写す代わりに `r sprintf("%.2f", est)` と書きます. PDF の上ではどちらも同じに見えますが, 手で書いた数字は, 推定をやり直しても古いまま残ります. インラインコードなら, 推定をやり直すと本文の数値も一緒に更新されます (図 11.15). 配布した main.qmd では, 標本のユニット数と期間の数, (3) 列の推定値をこの方法で書いています.

図 11.15: 手で書いた数字とインラインコード

原稿をパイプラインに組み込む

R/tar_manuscript.R では, tar_quarto() で原稿をパイプラインに組み込みます.

R/tar_manuscript.R
tar_manuscript <- tar_plan(
  tar_quarto(
    manuscript,
    "manuscript/main.qmd",
    extra_files = "manuscript/references.bib"
  )
)

tar_quarto() は, main.qmd のコードチャンクから tar_load() と tar_read() の呼び出しを探し, そこに書かれたターゲットを manuscript の依存関係に加えます. この例では tab_balance, trends, models_did, model_event の4つです. 原稿で新しいターゲットを tar_load() すれば, それも自動で依存関係に加わるので, パイプラインの側を書き換える必要はありません. main.qmd そのものも追跡されるので, 推定をやり直したときも, 原稿を書き換えたときも, tar_make() で PDF がレンダリングし直されます. 文献のファイルは自動では追跡されないので, extra_files で渡しています.

警告tar_quarto() が読み取れない書き方

tar_quarto() が読み取れるのは, .qmd のコードチャンクにターゲットの名前をそのまま書いた tar_load() と tar_read() だけです. source() で読み込んだ R ファイルの中の tar_load() や, tar_load(starts_with("model")) のような書き方は依存関係になりません. その場合, 上流が変わっても PDF は古い結果のまま残り, tar_make() もそれを教えてくれません. 原稿で使うターゲットは, .qmd の冒頭のチャンクに名前で並べておきます.

パイプラインを実行する

tar_make() を実行すると, データの読み込みから集計と推定を経て, PDF のレンダリングまでが一度に走ります.

targets::tar_make()

できあがった PDF が 図 11.16 です. 表も図も本文中の数値も, すべて main.qmd がパイプラインの結果から作っています.

図 11.16

パイプラインを図解すると 図 11.17 のようになります. 四角いノードがオブジェクト, 角の丸いノードがファイルのターゲット, 黒い旗形のノードが関数です. tar_analysis の4つのオブジェクトが, そのまま原稿 (manuscript) につながっています. この4本の矢印は, tar_quarto() が main.qmd の tar_load() から引いたものです.

図 11.17: Quarto パイプラインの依存関係

図表を原稿の中で作ると, 見た目を変えるときに触るのは main.qmd だけになり, 推定はやり直されません. 同じ推定結果から, 論文とスライドで大きさやフォントの違う図を作ることもできます.

配布した targets-quarto/ で, 次の3つの変更を順に試してみましょう. それぞれ, tar_make() で計算し直されるターゲットの数を予想してから実行します.

  1. manuscript/main.qmd の theme_set() で, テーマを theme_minimal() にする
  2. R/tar_analysis.R の estimate_event() の式から x1 を外す
  3. R/tar_data.R の clean_did() の末尾に, filter(abs(x1) < 8) を足す
  1. 計算し直されるのは原稿 (manuscript) だけです (1 completed, 7 skipped). 図の見た目は原稿の中で決まるので, 推定はやり直されません.
  2. イベントスタディ (model_event) と原稿が計算し直されます (2 completed, 6 skipped). 差の差の推定や集計表は, estimate_event() を使っていないので飛ばされます.
  3. クリーニング後のデータ (did) から下流が, すべて計算し直されます (6 completed, 2 skipped). 標本が変わるので, 本文にインラインコードで書いた推定値も変わります.

このパイプラインを, 試行錯誤のノートや発表スライドまで含めた研究全体に広げたのが, 次の節で紹介するワークフローです.

11.4 研究のワークフロー

ここから, 私が実際に論文を書く際に使っているワークフローを紹介します. セクション 11.3 のパイプラインに, 試行錯誤のノートと発表スライドを加えたものです. GitHubのテンプレートにもなっているので, そのまま使うこともできますし, 自分の研究に合わせてカスタマイズすることもできます.

フォルダ構成

テンプレートは, 次のようなフォルダ構成になっています.

template-research/
├── _targets.R        # pipeline composition
├── R/                # pipeline code: tar_data.R, tar_analysis.R, ...
├── data/             # raw data (gitignored)
├── notes/            # exploratory Quarto notes (NN-name/)
├── slides/           # presentation decks (YYMMDD_venue/)
├── manuscript/       # paper: Quarto Book + Typst
├── references.bib    # bibliography
├── rproject.toml     # R version + packages (managed by rv)
└── CLAUDE.md         # project conventions for the AI assistant

中心にあるのは, _targets.R と R/tar_*.R で定義するパイプラインです. セクション 11.3 と同じく, パイプラインは data/ のデータを読み込み, クリーニングし, 推定値や集計表といったデータオブジェクトを作るところまでを受け持ちます.

Quarto 文書は3種類あり, パイプラインとの付き合い方は2通りです.

フォルダ 役割 パイプラインの結果 上流が変わったとき
notes/ 試行錯誤 読まない 反映されない
slides/ 発表スライド 読まない 反映されない
manuscript/ 論文 tar_load() 反映される

論文は常に最新の結果を反映すべきなのでパイプラインに組み込みます. 一方, ノートとスライドは「その時点で何を試したか」「その日に何を発表したか」の記録なので, パイプラインの外に置き, 必要なテーマ, データ, 結果を自分のフォルダの中に持ちます. 詳しい理由はステップ4で説明します.

図 11.18 は, この切り分けを図にしたものです. 左の notes/ と slides/ は試行錯誤と共有の記録で, 右の R/ と manuscript/ が本番のパイプラインです. 2つをつなぐのは, 固まった分析を notes/ から R/ に移す一方向の矢印だけです (ステップ6).

図 11.18: 試行錯誤と本番のパイプラインの切り分け

手順

研究の進み具合に合わせて, 次の順にステップを進めます.

  1. テンプレートからプロジェクトを作る (_targets.R)
  2. 生データを登録し, クリーニングを定義する (R/tar_data.R)
  3. 図の見た目を1か所で定義する (R/tar_figure.R)
  4. ノートで試行錯誤する (notes/NN-name/index.qmd)
  5. 途中結果をスライドにまとめる (slides/YYMMDD_venue/index.qmd)
  6. 4-5 を繰り返し, 固まった結果をパイプラインに昇格させる (R/tar_analysis.R)
  7. 論文を執筆する (manuscript/, R/tar_manuscript.R)

論文を書き終えた頃には, 図 11.19 のようなパイプラインができあがっているはずです. ノートとスライドはパイプラインの外にあるので, この図には現れません.

図 11.19: 論文を書き終えた頃にできあがっているパイプライン

以下では, このステップを順に解説します.

1. テンプレートからプロジェクトを作る

GitHub でテンプレートから自分のリポジトリを作り, クローンします. R のバージョンとパッケージは rproject.toml に書かれており, rv sync で手元に再現できます (R の環境の揃え方は チャプター 2 を参照してください). そのうえで R から targets::tar_make() を実行すると, サンプルのパイプラインが最後まで動きます.

プロジェクト全体の入り口になるのが _targets.R です.

_targets.R
library(targets)
library(tarchetypes)
suppressPackageStartupMessages(library(dplyr))

tar_config_set(
  store = here::here("_targets"),
  script = here::here("_targets.R")
)

tar_option_set(
  packages = c("dplyr", "tidyr", "forcats", "stringr", "readr", "ggplot2")
)

tar_source()
tar_plan(
  tar_figure,
  tar_data,
  tar_analysis,
  tar_manuscript
)

tar_source() で R/ を読み込み, サブプランの名前を tar_plan() に並べる形は セクション 11.3 と同じです. 増えているのは, 図の見た目をまとめる tar_figure (ステップ3) です.

新しいのは tar_config_set() です. tar_make() で計算した結果は, プロジェクトのルートにある _targets/ ディレクトリ (ストア) に保存されます. この設定は, パイプラインの定義 (_targets.R) とストアの場所をルートに固定します. 論文は manuscript/ の中でレンダリングされるので, どこから呼ばれても同じストアを参照できるようにしておきます.

2. 生データを登録し, クリーニングを定義する

データの準備は R/tar_data.R に定義します. tar_file_read() で生データを登録し, クリーニングの関数に渡す形は セクション 11.2 と同じです.

R/tar_data.R
tar_data <- tar_plan(
  tar_file_read(
    survey_raw,
    here_rel("data", "survey.csv"),
    readr::read_csv(!!.x)
  ),
  survey = clean_survey(survey_raw)
)

clean_survey <- function(survey_raw) {
  survey_raw |>
    filter(!is.na(wage))
}

ここで大事なのは, クリーニング後のデータをファイルに書き出さないことです. ストアの中にだけ置き, 論文からは tar_load() で読み込みます. 中間ファイルを作ると, どのファイルがいつの計算の結果なのかを人間が覚えておくことになり, パイプラインに任せた意味が薄れます.

オンライン上のファイルをダウンロードして tar_file() で登録したい場合は,

download_file <- function(url, destfile) {
  if (!file.exists(destfile)) {
    download.file(url, destfile)
  }
  return(destfile)
}

といった関数を定義して, tar_file() の中で呼び出すことができます. 保存先のパスを返す関数を定義するというのがポイントです.

3. 図の見た目を1か所で定義する

論文の図の見た目は, ggplot のテーマや色として R/tar_figure.R の1か所で定義します.

R/tar_figure.R
tar_figure <- tar_plan(
  fn_figure = list(
    theme_proj = theme_proj,
    color_accent = color_accent
  )
)

theme_proj <- function(size_base = 11) {
  ggplot2::theme_classic(base_size = size_base)
}

color_accent <- "#107895"

テーマの関数や色をリストにまとめて, fn_figure という1つのターゲットにしているのがポイントです. 論文は節ごとにファイルが分かれていますが, このターゲットを読み込むだけでどのファイルでも同じテーマと色が使えます. さらに, テーマを変更すると論文が再レンダリングの対象になります. 読み込み方はステップ7で説明します.

これを読むのは論文だけです. ノートとスライドは, 次のステップで述べるとおり自分のフォルダにテーマの凍結コピーを持ちます.

4. ノートで試行錯誤する

データ分析やモデルの試行錯誤は, notes/ の中で行います. 1つのフォルダが1ラウンドの試行錯誤にあたり, notes/01-descriptive/, notes/02-did/ のように番号を付けて並べます. 各フォルダは次のような中身を持ちます.

notes/01-descriptive/
├── index.qmd        # the note itself; reads only the CSVs in output/
├── code/            # setup.R and numbered scripts (01_*.R, ...)
├── output/          # CSVs the scripts write (gitignored)
└── data -> ../data  # symlink to notes/data/

各ノートは, それだけで完結した小さなプロジェクトです. コードは他のノートと共有せず, データのクリーニングのコードすら, ノートごとに持ちます. 計算は code/ の番号付きのスクリプトが行い, 結果を output/ に CSV で書き出します. index.qmd はその CSV を読んで図表を作るだけで, 自分では計算しません. output/ は code/ からいつでも作り直せるので, Git の管理からは外しています.

一方で, データはノート間で共有します. コードと違ってデータは大きく, ノートごとに複製するとコストが高いからです. data は notes/data/ へのシンボリックリンクで, ノートの段階でだけ使うデータセットの置き場所になっています.

ノートはパイプラインを一切読みません. tar_read() や tar_load() を使わず, そのノートに必要なテーマ, データ, 結果を, すべて自分のフォルダの中に持ちます. 冒頭では, それらをまとめた code/setup.R を読み込みます. どこからレンダリングしても同じファイルを指すように, パスは here::here() で書きます.

notes/01-descriptive/index.qmd
# Self-contained: source this note's frozen setup, never tar_read() the pipeline
source(here::here("notes", "01-descriptive", "code", "setup.R"))
theme_set(theme_note())

ノートは「その時点で何を試したか」の記録です. もしノートがパイプラインを読んでいると, 半年後にレンダリングし直したときに, その後で変わった数値が表示されたり, ターゲットの名前が変わってエラーになったりします. 必要なものを自分で持つ凍結スナップショットにしておけば, パイプラインがその後どう変わっても, 書いたときと同じノートが再現できます.

もう一つのポイントは, 必要以上にパイプラインに組み込まないことです. 試行錯誤の段階のほとんどの分析は, 実際の論文には含まれません. それをパイプラインに組み込むと, ほとんど必要ないにも関わらず, 依存関係の管理が難しくなります. そのため, ノートでしか使わない計算はノートの中 (code/ と output/) に閉じておき, 最終的に必要なものだけをパイプラインに組み込みます.

5. 途中結果をスライドにまとめる

研究を進める中で, 途中結果を発表する機会はよくあります. 試行錯誤した中で, 重要な結果をスライドにまとめます. スライドは slides/YYMMDD_venue/ のように, 日付と発表の場で名前を付けたフォルダに作り, 中身はノートと同じく index.qmd, code/, output/, data の構成にします. スライドの作り方そのものは スライド の章を参照してください.

パイプラインとの付き合い方もノートと同じです. スライドもパイプラインを読まず, その発表に必要なものを自分のフォルダの中に持ちます. 使う結果は, ノートの output/ からスライドの output/ にコピーして, 発表した時点の内容に固定します. ノートと同じように, スライド専用の code/ と data/ を持たせて計算し直すのもよい方法です. 私は, SMM のように推定に長い時間がかかる結果を使うときは, コピーで済ませています. ノートとの違いはテーマだけで, スライドには投影用に文字を大きくしたテーマを使います.

slides/260720_seminar/index.qmd
# Self-contained: source this deck's frozen setup, never tar_load() the pipeline
source("code/setup.R")
theme_set(theme_slide())

「その日に何を発表したか」の記録として凍結しておく理由は, ノートと同じです. 半年後に古いスライドをレンダリングし直しても, 発表したときと同じスライドが再現できます.

6. 固まった結果をパイプラインに昇格させる

発表などでフィードバックをもらいながら, 4-5 を繰り返して研究を進めていきます. 論文に載せる結果が固まったら, ここで初めて, その分析を R/tar_analysis.R のパイプラインに移します. ノートの code/ にあったコードを関数にしてパイプラインに移すことを, ここでは昇格と呼びます. 昇格させた後も, ノートは試行錯誤の記録としてそのまま残します.

R/tar_analysis.R
tar_analysis <- tar_plan(
  analysis_wage_gap = fct_wage_gap(survey)
)

fct_wage_gap <- function(data) {
  data |>
    summarize(
      n = n(),
      mean_wage = mean(wage),
      .by = c(year, gender)
    )
}

テンプレートでは, 分析の関数を fct_*(), その結果のターゲットを analysis_* と名付けています. 関数はクリーニング済みのデータを引数にとり, 計算結果を数値, データフレーム, あるいはそれらのリストとして返します.

セクション 11.3 と同じく, ここでも図は作りません. 論文の中で, tar_load() したデータから ggplot で描きます. 同じ結果でも, 論文とスライドではフォントやサイズを変えたいことが多く, 図をファイルに固めてしまうとその調整がしにくくなるためです. LaTeX で執筆する場合はこれができないので, セクション 11.2 のように図表をファイルに書き出すことになります.

Julia のコードをパイプラインに組み込む

R の分析だけであれば, そのままパイプラインに組み込むことができますが, Julia のコードを組み込む場合は少し工夫が必要です. 例えば R/tar_model.R に tar_model というサブプランを作り, _targets.R の tar_plan() に tar_model を書き足します. 私は以下の方法を取っています.

  1. Julia のソースコードファイルとして読み込み (tar_file_read()), パイプラインに組み込む (jl_file_*)
  2. Julia のソースコードを R の system2() でコマンドライン実行する (run_model())
  3. Julia の中で実行される結果は, CSV や YAML などのファイルとして保存しておき, R の中で読み込む (tar_file_read())
R/tar_model.R
tar_model <- tar_plan(
  tar_map(
    values = list(name = c("main", "model")),
    names = name,
    tar_file_read(
      jl,
      here_rel("Julia", paste0(name, ".jl")),
      readLines(!!.x)
    )
  ),
  res_model = run_model(jl_file_main, jl_main, jl_model),
  tar_file_read(parameters, res_model[[1]], yaml::read_yaml(!!.x)),
  tar_file_read(demand_supply, res_model[[2]], read.csv(!!.x)),
  tar_file_read(equilibrium, res_model[[3]], yaml::read_yaml(!!.x))
)

run_model <- function(jl_file_main, ...) {
  system2(command = "julia", args = c("--project=.", jl_file_main))

  return(file.path(
    here_rel("output", "Julia"),
    c("parameters.yaml", "demand_supply.csv", "equilibrium.yaml")
  ))
}

ポイントは以下の2点です.

  1. Julia 内の依存関係も含められるように, run_model() の引数に全ての依存関係を指定する
  2. 結果を保存したファイルは, リストにまとめた上で, R から一つずつ読み込む

ちなみに, system2() で実行する場合, Julia のパスが通っていないことがあります (PC のデフォルトのシェルと R のシェルが異なるため). その場合は, .Renviron で定義した Julia のパスを .Rprofile で読み込むようにしています.

.Renviron
PATH_JULIA=/path/to/julia/
.Rprofile
Sys.setenv(
  PATH = paste(Sys.getenv("PATH_JULIA"), Sys.getenv("PATH"), sep = ":")
)

7. 論文を執筆する

論文の執筆は, manuscript/ の中で行います. manuscript/ はそれ自体が1つの Quarto プロジェクトで, 次の3ステップで論文を書きます.

  1. manuscript/_quarto.yml でディレクトリ自体を Quarto Book として設定する
  2. 節ごとにファイルを分けて, Quarto Book として執筆する (01-intro.qmd, 02-analysis.qmd, …)
  3. manuscript/manuscript.qmd に各ファイルをまとめ, Typst で PDF にする
manuscript/manuscript.qmd
{{< include 01-intro.qmd >}}
{{< include 02-analysis.qmd >}}
{{< include 03-conclusion.qmd >}}

各ファイルの冒頭では, manuscript/_setup.R を読み込みます.

manuscript/02-analysis.qmd
here::i_am("manuscript/_quarto.yml")
source(here::here("manuscript", "_setup.R"))

パイプラインの結果を読み込むのは, この _setup.R だけです.

manuscript/_setup.R
here::i_am("manuscript/_quarto.yml")
targets::tar_config_set(
  store = here::here("_targets"),
  script = here::here("_targets.R")
)

targets::tar_load(c(survey, analysis_wage_gap))
invisible(list2env(targets::tar_read(fn_figure), .GlobalEnv))
theme_set(theme_proj())

セクション 11.3 では tar_load() の store 引数でストアの場所を指定しましたが, ここでは tar_config_set() で指定しています. here::i_am() は, このファイルがプロジェクトのどこにあるかを here に教え, プロジェクトのルートを確定させます. 続く tar_config_set() でストアの場所を指定しておくと, 以降は store を毎回書かずに tar_load() や tar_read() が使えます. テンプレートの _setup.R には, ほかにパッケージの読み込みや, 本文の数値の桁を揃える関数もまとめてあります. あとは セクション 11.3 と同じく, 読み込んだデータから ggplot で図を描き, tinytable で表を作ります. 本文中の数値も, 手で書き写すのではなく, 読み込んだ結果からインラインコードで埋め込みます.

tar_load() を書くのは _setup.R の1か所に限り, 節ごとのファイルには書きません. その理由は, 次に述べる依存関係の登録にあります.

論文もパイプラインに組み込む

論文の PDF は, R/tar_manuscript.R でパイプラインに組み込みます. これにより, データや分析が変わったときに, tar_make() を実行すれば PDF まで作り直されます.

R/tar_manuscript.R
tar_manuscript <- tar_plan(
  tar_file(
    manuscript_src,
    list.files(
      here_rel("manuscript"),
      pattern = "\\.(qmd|yml|tex|typ|bib|lua|R)$",
      recursive = TRUE,
      full.names = TRUE
    )
  ),
  tar_target_raw(
    "manuscript_pdf",
    command = bquote(
      {
        manuscript_src
        list(..(lapply(manuscript_targets(), as.name)))
        quarto::quarto_render(here_rel("manuscript", "manuscript.qmd"))
        here_rel("manuscript", "manuscript.pdf")
      },
      splice = TRUE
    ),
    format = "file"
  )
)

manuscript_src は, manuscript/ にある原稿ファイルをすべてファイルとして登録します. _setup.R も含めるために, パターンに R が入っています. 原稿を書き換えると, これが変わったと判定されます.

manuscript_pdf の中にある list(...) の行は, 計算としては何もしていません. {targets} は, ターゲットを作るコードの中にどのターゲット名が現れるかを見て依存関係を判定します. quarto_render() の中で原稿がどのターゲットを読んでいるかまでは分からないので, 論文が使うターゲットの名前をここに並べて, 依存関係として登録しているのです.

ただし, その名前は手では書いていません. 同じファイルに定義した manuscript_targets() が _setup.R を読み, その中の tar_load() と tar_read() に現れるターゲット名を返します. それを bquote() でコードに差し込んでいます. _setup.R に新しいターゲットの tar_load() を書けば, それだけで依存関係にも加わります. セクション 11.3 の tar_quarto() が1つの .qmd に対してやっていることを, 節ごとに分かれた原稿に対して行っている形です.

名前の一覧を手で書く形にすると, いずれ書き足し忘れが起きます. 論文が読んでいるターゲットを一覧に加え忘れると, そのターゲットが変わっても tar_make() は PDF を最新だと判断し, PDF には古い数値が残ります. {targets} から見れば何も古くなっていないので, tar_outdated() も警告しません. tar_load() を _setup.R の1か所に限っているのも, 節ごとのファイルに書いた tar_load() は, この読み取りから漏れるからです.

なぜ Quarto Book として執筆するのか

Quarto Book として執筆すると, 複数ファイルに分けても cross-reference が効きます. これはかなり便利で, セクションごとにファイルを分けることで論文の構造が把握しやすくなり, cross-reference の入力補完が働くことで, 快適に執筆できます.

ただし, Quarto Book は出力形式としては論文に相応しくないので, それらのファイルをまとめて manuscript/manuscript.qmd でコンパイルしています. この時, バックエンドは高速でコンパイルできる Typst を使用しています. ちなみに LaTeX のソースコードが必要になった場合は, quarto::quarto_render() で LaTeX のソースコードを生成することができます.