差分の差分法

作者

宋財泫(関西大学)

View slides in full screen

1 セットアップ

 実習に必要なパッケージとデータを読み込む。

pacman::p_load(tidyverse,     # Rの必須パッケージ
               haven,         # Stata形式データの読み込み
               broom,         # 推定結果を表に
               summarytools,  # 記述統計
               modelsummary,  # 推定結果の要約
               fixest,        # TWFE推定用
               fect,          # Staggeredデータ用
               panelView)     # パネルデータの可視化

did_df <- read_csv("_data/García-Montoya_et_al_2022.csv")

did_df
# A tibble: 18,749 × 14
   county state  year shooting fatal_shooting non_fatal_shooting turnout demvote population
    <dbl> <chr> <dbl>    <dbl>          <dbl>              <dbl>   <dbl>   <dbl>      <dbl>
 1   1001 01     1996        0              0                  0    56.1    32.5      40207
 2   1001 01     2000        0              0                  0    56.8    28.7      44021
 3   1001 01     2004        0              0                  0    60.2    23.7      48366
 4   1001 01     2008        0              0                  0    64.1    25.8      53277
 5   1001 01     2012        0              0                  0    61.0    26.5      55027
 6   1001 01     2016        0              0                  0    60.7    24.0      55416
 7   1003 01     1996        0              0                  0    51.8    27.1     125412
 8   1003 01     2000        0              0                  0    54.6    24.8     141342
 9   1003 01     2004        0              0                  0    59.8    22.5     156266
10   1003 01     2008        0              0                  0    62.4    23.8     175827
# ℹ 18,739 more rows
# ℹ 5 more variables: non_white <dbl>, change_unem_rate <dbl>, county_f <dbl>, state_f <chr>,
#   year_f <dbl>

 本データはGarcía-Montoya, Arjona, and Lacombe(2022)の再現用データ(の一部)である。これは学校内の銃撃事件の発生が投票参加および民主党候補者の得票率に与える因果効果の推定を試みた研究である。データはカウンティ \(\times\) 年のパネルデータである。変数の説明は以下の通りである。

変数名 説明
county カウンティー(郡)のID
state 州ID
year
shooting 学校内銃撃事件の発生
fatal_shooting 深刻な学校内銃撃事件の発生
non_fatal_shooting 軽微な学校内銃撃事件の発生
turnout 大統領選挙の投票率
demvote 民主党候補者の得票率
population 人口(カウンティー)
non_white 非白人の割合(カウンティー)
change_unem_rate 失業率の変化(カウンティー)

 ここでは本研究のメイン分析結果であるFigure 3と4の再現を目指す。

 DID推定には時間(年)とカウンティ(郡)の固定効果を投入し、州レベルでクラスタリングした標準誤差を使う予定である。これらの変数を予めfactor化しておこう。factor化した変数は変数名の後ろに_fを付けて、新しい列として追加しておこう。

did_df <- did_df |>
  mutate(county_f = factor(county),
         state_f  = factor(state),
         year_f   = factor(year))

did_df
# A tibble: 18,749 × 14
   county state  year shooting fatal_shooting non_fatal_shooting turnout demvote population
    <dbl> <chr> <dbl>    <dbl>          <dbl>              <dbl>   <dbl>   <dbl>      <dbl>
 1   1001 01     1996        0              0                  0    56.1    32.5      40207
 2   1001 01     2000        0              0                  0    56.8    28.7      44021
 3   1001 01     2004        0              0                  0    60.2    23.7      48366
 4   1001 01     2008        0              0                  0    64.1    25.8      53277
 5   1001 01     2012        0              0                  0    61.0    26.5      55027
 6   1001 01     2016        0              0                  0    60.7    24.0      55416
 7   1003 01     1996        0              0                  0    51.8    27.1     125412
 8   1003 01     2000        0              0                  0    54.6    24.8     141342
 9   1003 01     2004        0              0                  0    59.8    22.5     156266
10   1003 01     2008        0              0                  0    62.4    23.8     175827
# ℹ 18,739 more rows
# ℹ 5 more variables: non_white <dbl>, change_unem_rate <dbl>, county_f <fct>, state_f <fct>,
#   year_f <fct>

 連続変数(shootingからchange_unem_rateまで)の記述統計量を出力する。

did_df |>
  select(shooting:change_unem_rate) |>
  descr(stats = c("mean", "sd", "min", "max", "n.valid"),
        transpose = TRUE, order = "p")
Descriptive Statistics  
did_df  
N: 18749  

                               Mean     Std.Dev      Min           Max    N.Valid
------------------------ ---------- ----------- -------- ------------- ----------
                shooting       0.00        0.07     0.00          1.00   18749.00
          fatal_shooting       0.00        0.05     0.00          1.00   18749.00
      non_fatal_shooting       0.00        0.04     0.00          1.00   18749.00
                 turnout      57.94       10.32     1.08        300.00   18627.00
                 demvote      39.01       13.79     3.14         92.85   18627.00
              population   95121.69   306688.63    55.00   10137915.00   18749.00
               non_white       0.13        0.16     0.00          0.96   18749.00
        change_unem_rate      -0.39        2.34   -19.60         15.30   18749.00
ノートselect()関数がおかしい?

 今回の講義に限らず、よくある問題としてパッケージ間の衝突がある。これは同じ名前の関数が複数存在する際に生じる。たとえば、select()関数は{dplyr}パッケージだけでなく、{MASS}という有名なパッケージにも同じ名前の関数が存在する。自分が{dplyr}のみ読み込み、{MASS}を読み込まなかったとしても衝突は生じうる。たとえば、{X}というパッケージが{MASS}に依存する場合、{X}を読み込むと、裏では{MASS}も読み込まれるからだ。そもそも我々も{dplyr}を読み込んでないが、{tidyverse}が{dplyr}に依存しているため、{dplyr}が読み込まれている。

 したがって、絶対存在するはずの関数で使い方も間違っていないにも関わらずエラーが発生した場合は、「どのパッケージの関数か」を明記してみよう。書き方はパッケージ名::関数名()だ。たとえば、{dplyr}パッケージのselect()関数はdplyr::select()と書く。

2 差分の差分法

2.1 TWFE

 それでは二元配置固定効果(Two-way fixed effect; TWFE )モデルで差分の差分法を実装する方法について紹介する。推定式は以下の通りである。

\[ \mbox{Outcome}_{i, t} = \beta_0 + \beta_1 \mbox{Shooting}_{i, t} + \sum_k \delta_{k, i, t} \mbox{Controls}_{k, i, t} + \lambda_{t} + \omega_{i} + \varepsilon_{i, t} \]

  • \(i\):カウンティ
  • \(t\):年
  • \(\mbox{Otucome}\):応答変数
    • turnout:投票率(大統領選挙)
    • demvote:民主党候補者の得票率
  • \(\mbox{Shooting}\):処置変数
    • shooting:銃撃事件の発生有無
    • fatal_shooting:死者を伴う銃撃事件の発生有無
    • non_fatal_shooting:死者を伴わない銃撃事件の発生有無
  • \(\mbox{Controls}_k\):統制変数
    • \(\mbox{Controls}_1\)population):カウンティーの人口
    • \(\mbox{Controls}_2\)non_white):非白人の割合
    • \(\mbox{Controls}_3\)change_unem_rate):失業率の変化
    • 統制変数あり/なしのモデルを個別に推定
  • \(\lambda\):年固定効果
  • \(\omega\):カウンティー固定効果

 今回は応答変数が2種類、処置変数が3種類、共変量の有無でモデルを分けるので、推定するモデルは計12個である。

モデル オブジェクト名 応答変数 処置変数 統制変数
モデル1 did_fit1 turnout shooting -
モデル2 did_fit2 turnout shooting \(\bigcirc\)
モデル3 did_fit3 turnout fatal_shooting -
モデル4 did_fit4 turnout fatal_shooting \(\bigcirc\)
モデル5 did_fit5 turnout non_fatal_shooting -
モデル6 did_fit6 turnout non_fatal_shooting \(\bigcirc\)
モデル7 did_fit7 demvote shooting -
モデル8 did_fit8 demvote shooting \(\bigcirc\)
モデル9 did_fit9 demvote fatal_shooting -
モデル10 did_fit10 demvote fatal_shooting \(\bigcirc\)
モデル11 did_fit11 demvote non_fatal_shooting -
モデル12 did_fit12 demvote non_fatal_shooting \(\bigcirc\)

 まずはモデル1を推定し、did_fit1という名のオブジェクトとして格納する。基本的には線形回帰分析であるため、lm()でも推定はできる。しかし、差分の差分法の場合、通常、クラスター化した頑健な標準誤差(cluster robust standard error)を使う。lm()単体ではこれが計算できないため、今回は{fixest}パッケージが提供するfeols()関数を使用する。使い方はlm()に似ている。まず、回帰式を書く。たとえば、モデル1の場合、trunout ~ shootingである。ただし、feols()で固定効果を投入するには、回帰式の右側に|を追加し、その右側に固定効果として投入する変数を指定する必要がある。たとえば、county_f変数とyear_f変数が固定効果として投入される場合、trunout ~ shooting | county_f + year_fが回帰式である1。最後に標準誤差をクラスタリングする単位はcluster引数で指定する。たとえば、州(state_f)単位で標準誤差をクラスタリングしたい時はcluster = ~ state_fと書く。

 本研究のデータは1行がCounty \(\times\) Yearを意味するパネルデータだが、クラスター標準誤差を推定する際、カウンティでなく、州でクラスタリングしている。これについては明確な理由は述べられていない。もしかしたら、同一州内のカウンティ間の政治的トレンドの相関が理論的に懸念されたかも知れない。また、より保守的にアプローチし、より粗い方でクラスタイングをした可能性もある(Abadie et al. 2023)2

 そもそも、本研究は政治学トップジャーナルであるAmerican Political Science Review(APSR)に掲載された論文であるが、APSRへの掲載が本論文の方法論的正しさを保証するわけではない(むろん、トップジャーナルであれば相対的にクオリティーの良い論文が見つかりやすいだろう)。実際、本研究に対して方法論的に辛辣な批判をした研究もあり、彼らの研究によると、García-Montoya et al.(2022)の結果は過大評価されており、実際の因果効果はほぼゼロだという(Hassell and Holbein 2025)3。また、Hassell and Holbein(2025)では州単位でクラスタリングした標準誤差が使用されており、州単位でのクラスタリングが適切だった可能性もある。。

did_fit1 <- feols(turnout ~ shooting | county_f + year_f, 
                  data    = did_df, 
                  cluster = ~ state_f)

summary(did_fit1)
OLS estimation, Dep. Var.: turnout
Observations: 18,620
Fixed-effects: county_f: 3,104,  year_f: 6
Standard-errors: Clustered (state_f) 
          Estimate Std. Error  t value Pr(>|t|) 
shooting -0.521084   0.589288 -0.88426  0.38087 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
RMSE: 3.89827     Adj. R2: 0.828867
                Within R2: 6.907e-5

 処置効果の推定値は-0.521である。これは学校内銃撃事件が発生したカウンティーの場合、大統領選挙において投票率が約-0.521パーセンテージポイント(pp)低下することを意味する。しかし、標準誤差がかなり大きく、統計的有意な結果ではない。つまり、「学校内銃撃事件が投票率を上げる(or 下げる)とは言えない」と解釈できる。決して「学校内銃撃事件が投票率を上げない(or 下げない)」と解釈しないこと。

 共変量を投入してみたらどうだろうか。たとえば、人口は自治体の都市化程度を表すこともあるので、都市化程度と投票率には関係があると考えられる。また、人口が多いと自然に事件が発生する確率もあがるので、交絡要因として考えられる。人種や失業率も同様であろう。ここではカウンティーの人口(population)、非白人の割合(non_white)、失業率の変化(change_unem_rate)を統制変数として投入し、did_fit2という名で格納する。統制変数は|の左側に+演算して加える。

did_fit2 <- feols(turnout ~ shooting + 
                    population + non_white + change_unem_rate | county_f + year_f, 
                  data          = did_df, 
                  cluster       = ~ state_f)

summary(did_fit2)
OLS estimation, Dep. Var.: turnout
Observations: 18,620
Fixed-effects: county_f: 3,104,  year_f: 6
Standard-errors: Clustered (state_f) 
                     Estimate  Std. Error  t value Pr(>|t|)    
shooting          -0.70979224  0.56925534 -1.24688 0.218369    
population         0.00000803  0.00000519  1.54653 0.128411    
non_white        -34.83442620 14.21507415 -2.45053 0.017882 *  
change_unem_rate   0.15920199  0.06009365  2.64923 0.010828 *  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
RMSE: 3.86574     Adj. R2: 0.831678
                Within R2: 0.016687

 処置効果の推定値は-0.710である。これは他の条件が同じ場合、学校内銃撃事件が発生したカウンティーは大統領選挙において投票率が約-0.710pp低下することを意味する。今回も統計的に有意な因果効果は確認されなかった。

 これまでの処置変数は死者の有無と関係なく、学校内銃撃事件が発生したか否かだった。もしかしたら、死者を伴う銃撃事件が発生した場合、その効果が大きいかも知れない。したがって、これからは処置変数を死者を伴う学校内銃撃事件の発生有無(fatal_shooting)、死者を伴わない学校内銃撃事件の発生有無(non_fatal_shooting)に変えてもう一度推定してみよう。

did_fit3 <- feols(turnout ~ fatal_shooting | county_f + year_f, 
                  data          = did_df, 
                  cluster       = ~ state_f)

did_fit4 <- feols(turnout ~ fatal_shooting + 
                    population + non_white + change_unem_rate | county_f + year_f, 
                  data          = did_df, 
                  cluster       = ~ state_f)

did_fit5 <- feols(turnout ~ non_fatal_shooting | county_f + year_f, 
                  data          = did_df, 
                  cluster       = ~ state_f)

did_fit6 <- feols(turnout ~ non_fatal_shooting + 
                    population + non_white + change_unem_rate | county_f + year_f, 
                  data          = did_df, 
                  cluster       = ~ state_f)

 これまで推定してきた6つのモデルを比較してみよう。

modelsummary(list(did_fit1, did_fit2, did_fit3, 
                  did_fit4, did_fit5, did_fit6))
(1) (2) (3) (4) (5) (6)
shooting -0.521 -0.710
(0.589) (0.569)
population 0.000 0.000 0.000
(0.000) (0.000) (0.000)
non_white -34.834 -34.844 -34.814
(14.215) (14.254) (14.251)
change_unem_rate 0.159 0.159 0.160
(0.060) (0.060) (0.060)
fatal_shooting -0.678 -0.918
(0.565) (0.600)
non_fatal_shooting -0.239 -0.327
(0.998) (1.045)
Num.Obs. 18620 18620 18620 18620 18620 18620
R2 0.857 0.860 0.857 0.860 0.857 0.860
R2 Adj. 0.829 0.832 0.829 0.832 0.829 0.832
R2 Within 0.000 0.017 0.000 0.017 0.000 0.017
R2 Within Adj. 0.000 0.016 0.000 0.016 -0.000 0.016
AIC 109727.5 109421.4 109727.4 109421.3 109728.6 109423.6
BIC 134085.0 133802.4 134084.9 133802.3 134086.1 133804.6
RMSE 3.90 3.87 3.90 3.87 3.90 3.87
FE: county_f X X X X X X
FE: year_f X X X X X X

 いずれのモデルも統計的に有意な処置効果は確認されていない。これらの結果を表として報告するには紙がもったいない気もする。これらの結果はOnline Appendixに回し、本文中には処置効果の点推定値と95%信頼区間を示せば良いだろう。

 {broom}のtidy()関数で推定結果のみを抽出し、それぞれオブジェクトとして格納しておこう。

tidy_fit1 <- tidy(did_fit1, conf.int = TRUE)
tidy_fit2 <- tidy(did_fit2, conf.int = TRUE)
tidy_fit3 <- tidy(did_fit3, conf.int = TRUE)
tidy_fit4 <- tidy(did_fit4, conf.int = TRUE)
tidy_fit5 <- tidy(did_fit5, conf.int = TRUE)
tidy_fit6 <- tidy(did_fit6, conf.int = TRUE)

 全て確認する必要はないので、tidy_fit1のみを確認してみる。

tidy_fit1
# A tibble: 1 × 7
  term     estimate std.error statistic p.value conf.low conf.high
  <chr>       <dbl>     <dbl>     <dbl>   <dbl>    <dbl>     <dbl>
1 shooting   -0.521     0.589    -0.884   0.381    -1.71     0.663

 以上の6つの表形式オブジェクトを一つの表としてまとめる。それぞれのオブジェクトには共変量の有無_処置変数の種類の名前を付けよう。共変量なしのモデルはM1、ありのモデルはM2とする。処置変数はshootingの場合はTr1fatal_shootingTr2non_fatal_shootingTr3とする。

did_est1 <- bind_rows(list("M1_Tr1" = tidy_fit1,
                           "M2_Tr1" = tidy_fit2,
                           "M1_Tr2" = tidy_fit3,
                           "M2_Tr2" = tidy_fit4,
                           "M1_Tr3" = tidy_fit5,
                           "M2_Tr3" = tidy_fit6),
                      .id = "Model")

did_est1
# A tibble: 15 × 8
   Model  term                   estimate   std.error statistic p.value     conf.low  conf.high
   <chr>  <chr>                     <dbl>       <dbl>     <dbl>   <dbl>        <dbl>      <dbl>
 1 M1_Tr1 shooting            -0.521       0.589         -0.884  0.381   -1.71        0.663    
 2 M2_Tr1 shooting            -0.710       0.569         -1.25   0.218   -1.85        0.434    
 3 M2_Tr1 population           0.00000803  0.00000519     1.55   0.128   -0.00000240  0.0000185
 4 M2_Tr1 non_white          -34.8        14.2           -2.45   0.0179 -63.4        -6.27     
 5 M2_Tr1 change_unem_rate     0.159       0.0601         2.65   0.0108   0.0384      0.280    
 6 M1_Tr2 fatal_shooting      -0.678       0.565         -1.20   0.236   -1.81        0.458    
 7 M2_Tr2 fatal_shooting      -0.918       0.600         -1.53   0.133   -2.12        0.288    
 8 M2_Tr2 population           0.00000803  0.00000525     1.53   0.132   -0.00000251  0.0000186
 9 M2_Tr2 non_white          -34.8        14.3           -2.44   0.0181 -63.5        -6.20     
10 M2_Tr2 change_unem_rate     0.159       0.0602         2.65   0.0109   0.0382      0.280    
11 M1_Tr3 non_fatal_shooting  -0.239       0.998         -0.239  0.812   -2.24        1.77     
12 M2_Tr3 non_fatal_shooting  -0.327       1.05          -0.313  0.756   -2.43        1.77     
13 M2_Tr3 population           0.00000782  0.00000521     1.50   0.140   -0.00000266  0.0000183
14 M2_Tr3 non_white          -34.8        14.3           -2.44   0.0182 -63.5        -6.18     
15 M2_Tr3 change_unem_rate     0.160       0.0601         2.65   0.0107   0.0387      0.280    

 続いて、処置効果のみを抽出する。処置効果はterm列の値が"shooting""fatal_shooting""non_fatal_shooting"のいずれかと一致する行であるため、filter()関数を使用する。

did_est1 <- did_est1 |>
  filter(term %in% c("shooting", "fatal_shooting", "non_fatal_shooting"))

did_est1
# A tibble: 6 × 8
  Model  term               estimate std.error statistic p.value conf.low conf.high
  <chr>  <chr>                 <dbl>     <dbl>     <dbl>   <dbl>    <dbl>     <dbl>
1 M1_Tr1 shooting             -0.521     0.589    -0.884   0.381    -1.71     0.663
2 M2_Tr1 shooting             -0.710     0.569    -1.25    0.218    -1.85     0.434
3 M1_Tr2 fatal_shooting       -0.678     0.565    -1.20    0.236    -1.81     0.458
4 M2_Tr2 fatal_shooting       -0.918     0.600    -1.53    0.133    -2.12     0.288
5 M1_Tr3 non_fatal_shooting   -0.239     0.998    -0.239   0.812    -2.24     1.77 
6 M2_Tr3 non_fatal_shooting   -0.327     1.05     -0.313   0.756    -2.43     1.77 

 ちなみにgrepl()関数を使うと、"shooting"が含まれる行を抽出することもできる。以下のコードは上記のコードと同じ機能をする。

did_est1 <- did_est1 |>
  filter(grepl("shooting", term))

 つづいて、Model列をModelTreat列へ分割する。

 separate()関数は列を任意の文字列を基準に複数の列へ分割する関数だ。{tidyr}に含まれている関数だが、{tidyr}は{tidyverse}を構成する一部だから、{tidyverse}を読み込んでおいたのであれば、別途読み込む必要はない。

 必要な必須引数は3つ、colintosepだ。colは分割する列名、into分割後に列名(c()でベクトルとして指定する)、sepが分割の基準となる文字列だ。以下の例ではModel列を「-」(ハイフン)を基準にModelTreatの2列に分割するコードだ。

データ |>
  separate(col  = Model,
           into = c("Model", "Treat"),
           sep  = "-")
Error:
! object 'データ' not found

 これを利用すれば、2026-08-19のような値が格納されている列を年、月、日に分割できる。

did_est1 <- did_est1 |>
  separate(col  = Model,
           into = c("Model", "Treat"),
           sep  = "_")

did_est1
# A tibble: 6 × 9
  Model Treat term               estimate std.error statistic p.value conf.low conf.high
  <chr> <chr> <chr>                 <dbl>     <dbl>     <dbl>   <dbl>    <dbl>     <dbl>
1 M1    Tr1   shooting             -0.521     0.589    -0.884   0.381    -1.71     0.663
2 M2    Tr1   shooting             -0.710     0.569    -1.25    0.218    -1.85     0.434
3 M1    Tr2   fatal_shooting       -0.678     0.565    -1.20    0.236    -1.81     0.458
4 M2    Tr2   fatal_shooting       -0.918     0.600    -1.53    0.133    -2.12     0.288
5 M1    Tr3   non_fatal_shooting   -0.239     0.998    -0.239   0.812    -2.24     1.77 
6 M2    Tr3   non_fatal_shooting   -0.327     1.05     -0.313   0.756    -2.43     1.77 

 可視化に入る前にModel列とTreat列の値を修正する。Model列の値が"M1"なら"County-Year FE"に、それ以外なら"County-Year FE + Covariates"とリコーディングする。戻り値が2種類だからif_else()を使う。Treat列の場合、戻り値が3つなので、recode()case_when()を使う。ここではrecode()を使ってリコーディングする。最後にModelTreatを表示順番でfactor化し(fct_inorder())、更に順番を逆転する(fct_rev())。

 fct_rev()関数はfactor型の要素の順番を逆転する関数だ。{forcats}に含まれている関数だが、{forcats}は{tidyverse}を構成する一部だから、{tidyverse}を読み込んでおいたのであれば、別途読み込む必要はない。

did_est1 <- did_est1 |>
  mutate(Model = if_else(Model == "M1",
                           "County-Year FE", 
                           "County-Year FE + Covariates"),
         Treat = recode(Treat,
                        "Tr1" = "Any Shooting (t-1)",
                        "Tr2" = "Fatal Shooting (t-1)",
                        "Tr3" = "Nonfatal Shooting (t-1)"),
         Model = fct_rev(fct_inorder(Model)),
         Treat = fct_rev(fct_inorder(Treat)))

did_est1
# A tibble: 6 × 9
  Model                       Treat    term  estimate std.error statistic p.value conf.low conf.high
  <fct>                       <fct>    <chr>    <dbl>     <dbl>     <dbl>   <dbl>    <dbl>     <dbl>
1 County-Year FE              Any Sho… shoo…   -0.521     0.589    -0.884   0.381    -1.71     0.663
2 County-Year FE + Covariates Any Sho… shoo…   -0.710     0.569    -1.25    0.218    -1.85     0.434
3 County-Year FE              Fatal S… fata…   -0.678     0.565    -1.20    0.236    -1.81     0.458
4 County-Year FE + Covariates Fatal S… fata…   -0.918     0.600    -1.53    0.133    -2.12     0.288
5 County-Year FE              Nonfata… non_…   -0.239     0.998    -0.239   0.812    -2.24     1.77 
6 County-Year FE + Covariates Nonfata… non_…   -0.327     1.05     -0.313   0.756    -2.43     1.77 

 それでは{ggplot2}を使ってpointrangeプロットを作成してみよう。

did_est1 |>
  ggplot() +
  # x = 0の箇所に垂直線を引く。垂直線は破線(dashed)とする。
  geom_vline(xintercept = 0, linetype = "dashed") +
  geom_pointrange(aes(x = estimate, xmin = conf.low, xmax = conf.high,
                      y = Treat, color = Model),
                  position = position_dodge2(1/2)) +
  labs(x = "Change in Turnout (pp)", y = "", color = "") +
  # 色を指定する。
  # Modelの値が County-Year FE なら黒、
  # County-Year FE + Covariates ならグレー、
  scale_color_manual(values = c("County-Year FE" = "black", 
                                "County-Year FE + Covariates" = "gray50")) +
  # 横軸の下限と上限を-10〜10とする。
  coord_cartesian(xlim = c(-10, 10)) +
  theme_classic(base_size = 12) +
  theme(legend.position = "bottom")

 元の論文を見ると、点の上に点推定値が書かれているが、私たちもこれを真似してみよう。文字列をプロットするレイヤーはgeom_text()geom_label()annotate()があるが、ここではgeom_text()を使用する。文字列が表示される横軸上の位置(x)と縦軸上の位置(y)、そして出力する文字列(label)をマッピングする。点推定値は3桁まで出力したいので、sprintf()を使って、3桁に丸める。ただし、これだけだと点と文字が重なってしまう。vjust-0.75にすることで、出力する文字列を点の位置を上の方向へ若干ずらすことができる。

did_est1 |>
  ggplot() +
  geom_vline(xintercept = 0, linetype = 2) +
  geom_pointrange(aes(x = estimate, xmin = conf.low, xmax = conf.high,
                      y = Treat, color = Model),
                  position = position_dodge2(1/2)) +
  geom_text(aes(x = estimate, y = Treat, color = Model, 
                label = sprintf("%.3f", estimate)),
            position = position_dodge2(1/2),
            vjust = -0.75) +
  labs(x = "Change in Turnout (pp)", y = "", color = "") +
  scale_color_manual(values = c("County-Year FE" = "black", 
                                "County-Year FE + Covariates" = "gray50")) +
  coord_cartesian(xlim = c(-10, 10)) +
  theme_classic(base_size = 12) +
  theme(legend.position = "bottom")

 ちなみにこのコードを見ると、geom_pointrange()geom_text()xycolorを共有しているので、ggplot()内でマッピングすることもできる。

did_est1 |>
  ggplot(aes(x = estimate, y = Treat, color = Model)) +
  geom_vline(xintercept = 0, linetype = 2) +
  geom_pointrange(aes(xmin = conf.low, xmax = conf.high),
                  position = position_dodge2(1/2)) +
  geom_text(aes(label = sprintf("%.3f", estimate)),
            position = position_dodge2(1/2),
            vjust = -0.75) +
  labs(x = "Change in Turnout (pp)", y = "", color = "") +
  scale_color_manual(values = c("County-Year FE" = "black", 
                                "County-Year FE + Covariates" = "gray50")) +
  coord_cartesian(xlim = c(-10, 10)) +
  theme_classic(base_size = 12) +
  theme(legend.position = "bottom")

 続いて、民主党候補者の得票率(demvote)を応答変数として6つのモデルを推定し、同じ作業を繰り返す。

did_fit7 <- feols(demvote ~ shooting | county_f + year_f, 
                  data    = did_df, 
                  cluster = ~ state_f)

did_fit8 <- feols(demvote ~ shooting + 
                    population + non_white + change_unem_rate | county_f + year_f, 
                  data    = did_df, 
                  cluster = ~ state_f)

did_fit9 <- feols(demvote ~ fatal_shooting | county_f + year_f, 
                  data    = did_df, 
                  cluster = ~ state_f)

did_fit10 <- feols(demvote ~ fatal_shooting + 
                     population + non_white + change_unem_rate | county_f + year_f, 
                   data    = did_df, 
                   cluster = ~ state_f)

did_fit11 <- feols(demvote ~ non_fatal_shooting | county_f + year_f, 
                   data    = did_df, 
                   cluster = ~ state_f)

did_fit12 <- feols(demvote ~ non_fatal_shooting + 
                     population + non_white + change_unem_rate | county_f + year_f, 
                   data    = did_df, 
                   cluster = ~ state_f)

modelsummary(list("Model 7"  = did_fit7,  "Model 8"  = did_fit8, 
                  "Model 9"  = did_fit9,  "Model 10" = did_fit10, 
                  "Model 11" = did_fit11, "Model 12" = did_fit12))
Model 7 Model 8 Model 9 Model 10 Model 11 Model 12
shooting 4.513 2.364
(0.799) (0.672)
population 0.000 0.000 0.000
(0.000) (0.000) (0.000)
non_white 86.873 86.866 86.796
(16.303) (16.296) (16.296)
change_unem_rate -0.139 -0.139 -0.140
(0.116) (0.116) (0.116)
fatal_shooting 4.404 1.782
(0.997) (0.763)
non_fatal_shooting 4.217 2.930
(1.185) (0.947)
Num.Obs. 18620 18620 18620 18620 18620 18620
R2 0.882 0.894 0.882 0.894 0.882 0.894
R2 Adj. 0.859 0.873 0.859 0.873 0.858 0.873
R2 Within 0.004 0.105 0.002 0.104 0.001 0.104
R2 Within Adj. 0.003 0.105 0.002 0.104 0.001 0.104
AIC 116926.6 114937.5 116953.4 114950.3 116967.9 114944.2
BIC 141284.1 139318.5 141310.9 139331.3 141325.3 139325.2
RMSE 4.73 4.48 4.73 4.48 4.73 4.48
FE: county_f X X X X X X
FE: year_f X X X X X X

 今回はいずれも統計的に有意な結果が得られている。例えば、モデル7(did_fit7)の場合、処置効果の推定値は4.513である。これは学校内銃撃事件が発生したカウンティーの場合、大統領選挙において民主党候補者の得票率が約4.513pp増加することを意味する。

 以上の結果を図としてまとめてみよう。これまでのコードと同じ手順で作図するため、説明は割愛する。

tidy_fit7  <- tidy(did_fit7, conf.int = TRUE)
tidy_fit8  <- tidy(did_fit8, conf.int = TRUE)
tidy_fit9  <- tidy(did_fit9, conf.int = TRUE)
tidy_fit10 <- tidy(did_fit10, conf.int = TRUE)
tidy_fit11 <- tidy(did_fit11, conf.int = TRUE)
tidy_fit12 <- tidy(did_fit12, conf.int = TRUE)

did_est2 <- bind_rows(list("M1_Tr1" = tidy_fit7,
                           "M2_Tr1" = tidy_fit8,
                           "M1_Tr2" = tidy_fit9,
                           "M2_Tr2" = tidy_fit10,
                           "M1_Tr3" = tidy_fit11,
                           "M2_Tr3" = tidy_fit12),
                      .id = "Model")

did_est2
# A tibble: 15 × 8
   Model  term                 estimate   std.error statistic     p.value   conf.low   conf.high
   <chr>  <chr>                   <dbl>       <dbl>     <dbl>       <dbl>      <dbl>       <dbl>
 1 M1_Tr1 shooting            4.51       0.799           5.65 0.000000807  2.91        6.12     
 2 M2_Tr1 shooting            2.36       0.672           3.52 0.000956     1.01        3.72     
 3 M2_Tr1 population          0.0000317  0.00000619      5.13 0.00000493   0.0000193   0.0000442
 4 M2_Tr1 non_white          86.9       16.3             5.33 0.00000248  54.1       120.       
 5 M2_Tr1 change_unem_rate   -0.139      0.116          -1.19 0.238       -0.373       0.0948   
 6 M1_Tr2 fatal_shooting      4.40       0.997           4.42 0.0000548    2.40        6.41     
 7 M2_Tr2 fatal_shooting      1.78       0.763           2.34 0.0237       0.248       3.32     
 8 M2_Tr2 population          0.0000321  0.00000621      5.17 0.00000436   0.0000196   0.0000445
 9 M2_Tr2 non_white          86.9       16.3             5.33 0.00000247  54.1       120.       
10 M2_Tr2 change_unem_rate   -0.139      0.116          -1.20 0.237       -0.373       0.0946   
11 M1_Tr3 non_fatal_shooting  4.22       1.18            3.56 0.000838     1.84        6.60     
12 M2_Tr3 non_fatal_shooting  2.93       0.947           3.10 0.00325      1.03        4.83     
13 M2_Tr3 population          0.0000323  0.00000613      5.27 0.00000308   0.0000200   0.0000446
14 M2_Tr3 non_white          86.8       16.3             5.33 0.00000250  54.0       120.       
15 M2_Tr3 change_unem_rate   -0.140      0.116          -1.20 0.235       -0.374       0.0938   
did_est2 <- did_est2 |>
  filter(grepl("shooting", term))

did_est2
# A tibble: 6 × 8
  Model  term               estimate std.error statistic     p.value conf.low conf.high
  <chr>  <chr>                 <dbl>     <dbl>     <dbl>       <dbl>    <dbl>     <dbl>
1 M1_Tr1 shooting               4.51     0.799      5.65 0.000000807    2.91       6.12
2 M2_Tr1 shooting               2.36     0.672      3.52 0.000956       1.01       3.72
3 M1_Tr2 fatal_shooting         4.40     0.997      4.42 0.0000548      2.40       6.41
4 M2_Tr2 fatal_shooting         1.78     0.763      2.34 0.0237         0.248      3.32
5 M1_Tr3 non_fatal_shooting     4.22     1.18       3.56 0.000838       1.84       6.60
6 M2_Tr3 non_fatal_shooting     2.93     0.947      3.10 0.00325        1.03       4.83
did_est2 <- did_est2 |>
  separate(col  = Model,
           into = c("Model", "Treat"),
           sep  = "_")

did_est2
# A tibble: 6 × 9
  Model Treat term               estimate std.error statistic     p.value conf.low conf.high
  <chr> <chr> <chr>                 <dbl>     <dbl>     <dbl>       <dbl>    <dbl>     <dbl>
1 M1    Tr1   shooting               4.51     0.799      5.65 0.000000807    2.91       6.12
2 M2    Tr1   shooting               2.36     0.672      3.52 0.000956       1.01       3.72
3 M1    Tr2   fatal_shooting         4.40     0.997      4.42 0.0000548      2.40       6.41
4 M2    Tr2   fatal_shooting         1.78     0.763      2.34 0.0237         0.248      3.32
5 M1    Tr3   non_fatal_shooting     4.22     1.18       3.56 0.000838       1.84       6.60
6 M2    Tr3   non_fatal_shooting     2.93     0.947      3.10 0.00325        1.03       4.83
did_est2 <- did_est2 |>
  mutate(Model = if_else(Model == "M1",
                           "County-Year FE", 
                           "County-Year FE + Covariates"),
         Treat = recode(Treat,
                        "Tr1" = "Any Shooting (t-1)",
                        "Tr2" = "Fatal Shooting (t-1)",
                        "Tr3" = "Nonfatal Shooting (t-1)"),
         Model = fct_rev(fct_inorder(Model)),
         Treat = fct_rev(fct_inorder(Treat)))

did_est2
# A tibble: 6 × 9
  Model                       Treat    term  estimate std.error statistic p.value conf.low conf.high
  <fct>                       <fct>    <chr>    <dbl>     <dbl>     <dbl>   <dbl>    <dbl>     <dbl>
1 County-Year FE              Any Sho… shoo…     4.51     0.799      5.65 8.07e-7    2.91       6.12
2 County-Year FE + Covariates Any Sho… shoo…     2.36     0.672      3.52 9.56e-4    1.01       3.72
3 County-Year FE              Fatal S… fata…     4.40     0.997      4.42 5.48e-5    2.40       6.41
4 County-Year FE + Covariates Fatal S… fata…     1.78     0.763      2.34 2.37e-2    0.248      3.32
5 County-Year FE              Nonfata… non_…     4.22     1.18       3.56 8.38e-4    1.84       6.60
6 County-Year FE + Covariates Nonfata… non_…     2.93     0.947      3.10 3.25e-3    1.03       4.83
did_est2 |>
  ggplot() +
  geom_vline(xintercept = 0, linetype = 2) +
  geom_pointrange(aes(x = estimate, xmin = conf.low, xmax = conf.high,
                      y = Treat, color = Model),
                  position = position_dodge2(1/2)) +
  geom_text(aes(x = estimate, y = Treat, color = Model, 
                label = sprintf("%.3f", estimate)),
            position = position_dodge2(1/2),
            vjust = -0.75) +
  labs(x = "Change in Democratic Vote Share (pp)", y = "", color = "") +
  scale_color_manual(values = c("County-Year FE" = "black", 
                                "County-Year FE + Covariates" = "gray50")) +
  coord_cartesian(xlim = c(-10, 10)) +
  theme_classic(base_size = 12) +
  theme(legend.position = "bottom")

 最後に、これまで作成した2つの図を一つにまとめてみよう。bind_rows()関数を使い、それぞれの表に識別子(Outcome)を与える。

did_est <- bind_rows(list("Out1" = did_est1,
                          "Out2" = did_est2),
                     .id = "Outcome")

did_est
# A tibble: 12 × 10
   Outcome Model                 Treat term  estimate std.error statistic p.value conf.low conf.high
   <chr>   <fct>                 <fct> <chr>    <dbl>     <dbl>     <dbl>   <dbl>    <dbl>     <dbl>
 1 Out1    County-Year FE        Any … shoo…   -0.521     0.589    -0.884 3.81e-1   -1.71      0.663
 2 Out1    County-Year FE + Cov… Any … shoo…   -0.710     0.569    -1.25  2.18e-1   -1.85      0.434
 3 Out1    County-Year FE        Fata… fata…   -0.678     0.565    -1.20  2.36e-1   -1.81      0.458
 4 Out1    County-Year FE + Cov… Fata… fata…   -0.918     0.600    -1.53  1.33e-1   -2.12      0.288
 5 Out1    County-Year FE        Nonf… non_…   -0.239     0.998    -0.239 8.12e-1   -2.24      1.77 
 6 Out1    County-Year FE + Cov… Nonf… non_…   -0.327     1.05     -0.313 7.56e-1   -2.43      1.77 
 7 Out2    County-Year FE        Any … shoo…    4.51      0.799     5.65  8.07e-7    2.91      6.12 
 8 Out2    County-Year FE + Cov… Any … shoo…    2.36      0.672     3.52  9.56e-4    1.01      3.72 
 9 Out2    County-Year FE        Fata… fata…    4.40      0.997     4.42  5.48e-5    2.40      6.41 
10 Out2    County-Year FE + Cov… Fata… fata…    1.78      0.763     2.34  2.37e-2    0.248     3.32 
11 Out2    County-Year FE        Nonf… non_…    4.22      1.18      3.56  8.38e-4    1.84      6.60 
12 Out2    County-Year FE + Cov… Nonf… non_…    2.93      0.947     3.10  3.25e-3    1.03      4.83 

 Outcome列のリコーディングし、factor化する。

did_est <- did_est |>
  mutate(Outcome = if_else(Outcome == "Out1",
                           "Change in Turnout (pp)",
                           "Change in Democratic Vote Share (pp)"),
         Outcome = fct_inorder(Outcome))

did_est
# A tibble: 12 × 10
   Outcome                 Model Treat term  estimate std.error statistic p.value conf.low conf.high
   <fct>                   <fct> <fct> <chr>    <dbl>     <dbl>     <dbl>   <dbl>    <dbl>     <dbl>
 1 Change in Turnout (pp)  Coun… Any … shoo…   -0.521     0.589    -0.884 3.81e-1   -1.71      0.663
 2 Change in Turnout (pp)  Coun… Any … shoo…   -0.710     0.569    -1.25  2.18e-1   -1.85      0.434
 3 Change in Turnout (pp)  Coun… Fata… fata…   -0.678     0.565    -1.20  2.36e-1   -1.81      0.458
 4 Change in Turnout (pp)  Coun… Fata… fata…   -0.918     0.600    -1.53  1.33e-1   -2.12      0.288
 5 Change in Turnout (pp)  Coun… Nonf… non_…   -0.239     0.998    -0.239 8.12e-1   -2.24      1.77 
 6 Change in Turnout (pp)  Coun… Nonf… non_…   -0.327     1.05     -0.313 7.56e-1   -2.43      1.77 
 7 Change in Democratic V… Coun… Any … shoo…    4.51      0.799     5.65  8.07e-7    2.91      6.12 
 8 Change in Democratic V… Coun… Any … shoo…    2.36      0.672     3.52  9.56e-4    1.01      3.72 
 9 Change in Democratic V… Coun… Fata… fata…    4.40      0.997     4.42  5.48e-5    2.40      6.41 
10 Change in Democratic V… Coun… Fata… fata…    1.78      0.763     2.34  2.37e-2    0.248     3.32 
11 Change in Democratic V… Coun… Nonf… non_…    4.22      1.18      3.56  8.38e-4    1.84      6.60 
12 Change in Democratic V… Coun… Nonf… non_…    2.93      0.947     3.10  3.25e-3    1.03      4.83 

 図の作り方はこれまでと変わらないが、ファセット分割を行うため、facet_wrap()レイヤーを追加する。

did_est |>
  ggplot() +
  geom_vline(xintercept = 0, linetype = 2) +
  geom_pointrange(aes(x = estimate, xmin = conf.low, xmax = conf.high,
                      y = Treat, color = Model),
                  position = position_dodge2(1/2)) +
  geom_text(aes(x = estimate, y = Treat, color = Model, 
                label = sprintf("%.3f", estimate)),
            position = position_dodge2(1/2),
            vjust = -0.75) +
  labs(x = "Treatment Effects", y = "", color = "") +
  scale_color_manual(values = c("County-Year FE" = "black", 
                                "County-Year FE + Covariates" = "gray50")) +
  coord_cartesian(xlim = c(-10, 10)) +
  facet_wrap(~Outcome, ncol = 2) +
  theme_bw(base_size = 12) +
  theme(legend.position = "bottom")

 以上の結果から「学校内銃撃事件の発生は投票参加を促すとは言えないものの、民主党候補者の得票率を上げる」ということが言えよう。

2.2 Staggered DID

 以上がGarcía-Montoya, Arjona, and Lacombe(2022)の主要結果の再現であるが、政治学トップジャーナルであるAPSRに掲載されたことが、本研究が完全無欠ということを意味するわけではない。なぜなら、TWFEによる推定値が信頼できる推定値であるためには、並行トレンドの仮定のような差分の差分法に関する仮定以外にもいくつかの条件がある。その一つが「処置が段階的に導入されない」ことだ。処置が段階的に導入されていないことは 図 1 のように処置群が同時期に処置を受け、一度処置を受けるとその状態が続くことを意味する。

図 1: 処置が段階的に導入されていない例

 一方、 図 2 は処置が段階的に導入されている例である。このようなデータを使ってTWFE推定を行うと、バイアスが生じることが知られている。学校内の銃撃事件は全米において同時多発的に起こるものではないので、処置群のカウンティが同時期に処置を受けることは考えられない。したがって、以上のTWFE推定量にはバイアスが含まれている可能性が高い。

図 2: 処置が段階的に導入されている例

 しかし、銃撃事件の場合は単なる段階的導入とは考えにくい。 図 2 では処置を受けるタイミングはユニットによって異なるが、一度処置を受けるとその状態は継続する。しかし、銃撃事件の場合、一度銃撃事件が起きたからといって、継続的に銃撃事件が発生するわけではない。つまり、García-Montoya, Arjona, and Lacombe(2022)のデータは @図 3 のような状態であると考えられる。

図 3: 可逆的な処置の例

 このように処置がOn-Offでスイッチできるようになっていることを、可逆的(reversible)な処置と呼ぶ。全カウンティを俯瞰することは難しいから、ここではコロラド州のみのデータを眺めてみよう。{panelView}パッケージのpanelview()関数を使えば良い。第一引数として応答変数 ~ 処置変数を指定し、data引数で使用するデータフレームのオブジェクト名、indexではユニットと時間を意味する変数を指定する。

did_df |> 
  filter(state == "06") |> 
  panelview(turnout ~ shooting, data = _, index = c("county", "year"),
            xlab = "Year", ylab = "County ID in Colorado")

 予想通り、銃撃発生事件の時期も同じでなく、処置状態が可逆的であることが確認できる。したがって、ここでは{fect}のfect()関数を使用し、より厳密に処置効果を推定してみよう。方法の詳細はLiu et al.(2024)を参照されたい。fect()が提供する反事実予測アプローチは処置が段階的に導入されているデータ( 図 2 )でも、可逆的な場合( 図 3 )でも使える。

 ここではdid_fit8と同じモデルをfect()を使って推定し、did_fit8_fectという名のオブジェクトとして保存してみよう。第1引数は回帰式であるが、固定効果で使用する変数(今回はcounty_fyear_f)は回帰式でなく、index = c("ユニット変数名", "時間変数名")引数で別途指定する必要がある。順番は必ずユニット、時間の順にし、"で囲む必要があることに注意すること。あとはdataでデータフレームのオブジェクト名を指定するだけだが、このままだと推定値の不確実性(標準誤差や信頼区間など)が推定できないため、se = TRUEで標準誤差も計算するように別途指定しておこう(少し時間がかかる)。標準誤差はブートストラップ法で計算されるが、そのためのサンプリング回数が既定値だと200になっている。ブートストラップとしては少し少なめではある。もし、1,000回にしたい場合はnboot = 1000引数を追加してみよう。ただし、その分推定時間が長くなるので注意。

did_fit8_fect <- fect(demvote ~ shooting + 
                        population + non_white + change_unem_rate, 
                      data  = did_df,
                      index = c("county_f", "year_f"),
                      se    = TRUE,
                      nboot = 1000)

did_fit8_fect
Call:
fect.formula(formula = demvote ~ shooting + population + non_white + 
    change_unem_rate, data = did_df, index = c("county_f", "year_f"), 
    se = TRUE, nboots = 1000)

Estimator:    fe
Fixed effects: county_f (unit) + year_f (time)

ATT:
                            ATT   S.E. CI.lower CI.upper   p.value
Tr obs equally weighted   2.155 0.7429   0.6990    3.611 0.0037217
Tr units equally weighted 2.279 0.6707   0.9642    3.593 0.0006799

Covariates:
                       Coef      S.E.   CI.lower   CI.upper   p.value
population        3.526e-05 5.531e-06  2.442e-05  0.0000461 1.819e-10
non_white         8.493e+01 5.678e+00  7.380e+01 96.0632949 0.000e+00
change_unem_rate -1.375e-01 2.797e-02 -1.923e-01 -0.0826865 8.830e-07

 処置効果が2つ出力されるが、基本的には1行目のTr obs equally weighted行を解釈すれば良い。今回の場合、処置効果の点推定値は2.155である。つまり、学校内銃撃事件が発生する場合、民主党候補者の得票率が約2.155pp増加することを意味する。 p 値は0.004であり、 p \(\leq\) 0.05のことから統計的に有意な因果効果であると解釈できる。

 TWFEから得られた推定結果(did_fit8)との違いも確認してみよう。

メソッド 処置効果の点推定値 95%信頼区間の下限 95%信頼区間の上限
fixest::feols() 2.364 1.046 3.682
fect::fect() 2.155 0.699 3.611

 {fect}パッケージを利用した反事実予測アプローチから得られた点推定量がやや0に近く、標準誤差も大きいことが分かる。分析する側として不確実性の推定値を使いたい気持ちは分かるが、このように処置が段階的に導入されたり4、可逆的な場合、通常のTWFEではバイアスが発生することが知られているため、別のアプローチを試すべきだろう。{fect}には様々な機能が提供されており、詳細は公式ホームページを参照されたい。

 また、fect()から得られた処置効果はATTであるに対し、TWFEはそうでないことにも注意されたい。TWFEから得られた推定量は、理論的にはATEに近いが、実はATEでもATTでもない推定量が得られる(ATEが得られるための条件がかなり厳しい)。ただし、feols()を使用した場合でも、内部にsunab()で処置の段階的導入を補正した場合や、i()関数でイベントスタディを実行した場合は、ATTが推定対象である。

2.3 イベントスタディ

 差分の差分法を用いた論文を執筆する際、イベントスタディ分析結果を掲載することが多い。イベントスタディを使えば、差分の差分法の最重要仮定である並行トレンドの仮定を検証し、推定量の頑健性を示すことができる。

 fect()で推定した場合、推定結果のオブジェクトをplot()関数に通すだけで、イベントスタディの図が出力される。以下が先ほど推定したdid_fit8_fectのイベントスタディの図を出力するコードだ。また、show.count = FALSE引数を追加すると当該時期の処置群のサンプルサイズの棒グラフが表示されなくなる。もし、必要であればshow.count = FALSEを省略するか、show.count = TRUEに変えれば良い。

plot(did_fit8_fect, show.count = FALSE)

 グラフでなく、具体的な数値が必要な場合は、オブジェクト名$est.attで確認できる。

estimand(did_fit8_fect, "att") # did_fit8_fect$est.att でも可
  event.time   estimate        se       ci.lo      ci.hi n_cells   vartype
1         -4 -1.9032050 1.0845719  -4.0289270  0.2225169      28 bootstrap
2         -3 -0.6098708 0.6743036  -1.9314815  0.7117399      42 bootstrap
3         -2 -1.4190416 0.5573555  -2.5114383 -0.3266449      56 bootstrap
4         -1 -0.7927063 0.5313384  -1.8341104  0.2486978      66 bootstrap
5          0  0.6177426 0.5492832  -0.4588328  1.6943180      81 bootstrap
6          1  2.6510247 0.6287457   1.4187058  3.8833437      81 bootstrap
7          2  0.3567839 7.3059680 -13.9626502 14.6762180       4 bootstrap

 理想としては t \(\leq\) 0において統計的に有意な効果がないこと。今回は t = -2において統計的に有意な効果が見られるが、イベントスタディの場合、一つ一つの効果を見るよりも全体的なトレンド(パターン)の方が重要である。もし、これが継続的に右上がりや右下がりなどの関係性を示している場合は致命的であるが、今回はそういうパターンは観察できず、t = -2の結果はノイズである可能性が高い。

 {fect}パッケージを使用したイベントスタディの場合、基準となる時期が不要である。講義スライドの例や{fixest}パッケージで推定した場合のイベントスタディは予め時期のベースラインを決めておく必要があり、そのベースラインにおけるATTは0と固定される。一方、{fect}はこういう作業が不要である。しかし、どの方法でも解釈方法は同じだ。

3 合成コントロール法

 ここではAbadie et al.(2014)の主要結果(Figure 1 & 2)を再現再現してみよう。元の論文では{synth}を使っているが、ここでは{fect}パッケージを使用し、一般化SCM(generalized SCM)で再現する5。アルゴリズムが異なるため、結果がぴったり一致することはないが、ほぼ再現できるはず。一般化SCMの詳細はXu(2017)を参照すること。

 まず、実習用データを読み込み、scm_dfと名付ける。

scm_df <- read_csv("_data/Abadie_et_al_2014.csv")

scm_df
# A tibble: 748 × 3
   country    year   gdp
   <chr>     <dbl> <dbl>
 1 Australia  1960  2373
 2 Australia  1961  2346
 3 Australia  1962  2539
 4 Australia  1963  2717
 5 Australia  1964  2873
 6 Australia  1965  2973
 7 Australia  1966  3230
 8 Australia  1967  3404
 9 Australia  1968  3788
10 Australia  1969  4162
# ℹ 738 more rows

 今回の実習で使用する変数は以下の3つである。

変数名 説明
year
country
gdp 一人当たり国内総生産(米ドル)

 処置変数は西ドイツにおける統一である。西ドイツと東ドイツの統一は1990年10月であるため、countryがWest Germanyでありながらyearが1990以上6の場合、1の値を、それ以外は0をとるtreatという名の列を作成する。

scm_df <- scm_df |> 
  mutate(treat   = if_else(country == "West Germany" & year >= 1990, 1, 0))

 panelview()関数でパネルデータの構造を確認してみよう。

scm_df |> 
  panelview(gdp ~ treat,
            index = c("country", "year"), 
            xlab = "Year", ylab = "Countries",
            background = "white",
            pre.post = TRUE)

 まず、Abadie et al.(2014)のFigure 1から再現してみよう。これは西ドイツとそれ以外の国のGDPを時系列で示したものである。まず、西ドイツとそれ以外に国を識別するためにwest_germany変数を作成し、gdpの平均値をwest_germanyyearごとに計算する。あとはgeom_line()レイヤーで折れ線グラフを作成するだけだ。図をより充実に再現するために、geom_vline()を使ってドイツが統一した1990年に垂直線を追加し、annotate()でラベルを加える。

scm_df |> 
  mutate(west_germany = if_else(country == "West Germany", "West Germany", "Other countries"),
         west_germany = fct_relevel(west_germany, "West Germany")) |> 
  summarise(gdp = mean(gdp),
            .by = c(west_germany, year)) |> 
  ggplot() +
  geom_vline(xintercept = 1990, color = "gray", linetype = "dashed") +
  geom_line(aes(x = year, y = gdp, linetype = west_germany)) +
  annotate("text", x = 1990, y = 25000, label = "Reunification →", hjust = 1.1) +
  labs(x = "Year", y = "GDP per capita (USD)", linetype = "") +
  theme_classic()

Abadie et al.(2014)のFigure 1の再現

 図を見ると並行トレンドの仮定は満たされておらず、やはりドイツ以外の国はドイツの潜在的結果(= 統一しなかった場合の西ドイツ)として適切ではないことが分かる。ここで合成コントロール法の出番だ。統一を経験していない一つ一つの国(統制群、またはドーナー)は西ドイツの代わりにはなれないが、これらをうまく合成すると統一していない西ドイツのトレンドが作成できる。それが合成コントロール法だ。

 それではfect()関数で一般化SCMを実行してみよう。使い方は差分の差分法の時とほぼ同じだが、method"fe"でなく、"gsynth"であることには注意したい。他にもrCVsevartpyenbootsといった引数が登場するが、とりあえず、以下のコードをそのまま使ってみよう(nbootsが大きいほど推定に時間がかかる)。

scm_fit <- fect(gdp ~ treat, 
                data = scm_df, index = c("country", "year"),
                method = "gsynth", force = "two-way",
                r = c(0, 10), CV = TRUE, 
                se = TRUE, vartype = "bootstrap", nboots = 1000)

scm_fit
Call:
fect.formula(formula = gdp ~ treat, data = scm_df, index = c("country", 
    "year"), force = "two-way", r = c(0, 10), CV = TRUE, method = "gsynth", 
    se = TRUE, vartype = "bootstrap", nboots = 1000)

Estimator:    gsynth
Fixed effects: country (unit) + year (time)

ATT:
                            ATT  S.E. CI.lower CI.upper   p.value
Tr obs equally weighted   -1616 425.2    -2449   -782.4 0.0001446
Tr units equally weighted -1616 425.2    -2449   -782.4 0.0001446

 それでは早速Abadie et al.(2014)のFigure 2を再現してみよう。plot()関数にtype = "counterfactual"を追加するだけで、同じような図が作成できる。

plot(scm_fit, type = "counterfactual")

Abadie et al.(2014)のFigure 2の再現

 実線が実際の西ドイツ(事実)、破線が合成された架空の西ドイツ、つまり統一しなかった場合の西ドイツ(反事実)だ。ドイツが統一する1990年までは事実と反事実はほぼ同じである。もし、この時点で2つのトレンドに大きな乖離があれば、SCMは失敗したことになる。そもそもSCMに適していないデータである可能性もあるが、推定時のパラメーターが原因である可能性もあるため、公式ホームページのマニュアルを見ながら試行錯誤して見るのも良いかも知れない。

 ドイツ統一以降は2つの線にギャップが生じており、これはドイツ統一が一人当たりGDPに影響を与えたことを示唆する。全体的に反事実の方が高い傾向を示していることから、ドイツの統一は西ドイツの人にとって、GDPの現象をもたらしたことを意味する(ただし、これは統計的に有意な因果効果かどうかは後ほど検討する)。

 図を作成する際、raw = "all"を追加すると統制群となる各国のトレンドも出力される。

plot(scm_fit, type = "counterfactual", raw = "all")

 この図は{ggplot2}で出来上がった図なので色々とカスタマイズができるが、100%自分好みの図を作るためにはゼロベースで作った方が良いだろう。架空の西ドイツにおける一人当たりGDPの具体的な数値はオブジェクト名$Y.avgで取得できる。treated列が事実、counterfactual列が反事実の一人当たりGDPである。

scm_fit$Y.avg
   period treated counterfactual lower.tr upper.tr lower90.tr upper90.tr  lower.ct  upper.ct
1       1    2284       2235.028     2284     2284       2284       2284  2141.887  2320.462
2       2    2388       2338.562     2388     2388       2388       2388  2267.463  2425.440
3       3    2527       2484.640     2527     2527       2527       2527  2430.426  2548.536
4       4    2610       2595.082     2610     2610       2610       2610  2551.827  2656.446
5       5    2806       2770.392     2806     2806       2806       2806  2730.438  2815.609
6       6    3005       2931.471     3005     3005       3005       3005  2894.863  2994.559
7       7    3168       3145.647     3168     3168       3168       3168  3086.321  3230.203
8       8    3241       3344.413     3241     3241       3241       3241  3289.026  3406.093
9       9    3571       3647.497     3571     3571       3571       3571  3580.424  3712.573
10     10    3998       4037.783     3998     3998       3998       3998  3945.543  4112.178
11     11    4367       4410.580     4367     4367       4367       4367  4325.068  4482.020
12     12    4686       4752.935     4686     4686       4686       4686  4679.606  4828.242
13     13    5055       5130.641     5055     5055       5055       5055  5056.857  5220.510
14     14    5553       5647.789     5553     5553       5553       5553  5547.385  5767.778
15     15    6074       6239.367     6074     6074       6074       6074  6139.500  6336.658
16     16    6603       6723.261     6603     6603       6603       6603  6641.903  6812.235
17     17    7367       7375.882     7367     7367       7367       7367  7298.458  7455.505
18     18    8090       8035.215     8090     8090       8090       8090  7929.220  8127.255
19     19    8928       8816.643     8928     8928       8928       8928  8687.190  8935.801
20     20   10067       9848.147    10067    10067      10067      10067  9724.963  9961.232
21     21   11083      10927.822    11083    11083      11083      11083 10795.848 11048.928
22     22   12115      12025.869    12115    12115      12115      12115 11901.756 12185.047
23     23   12761      12722.001    12761    12761      12761      12761 12523.807 12846.052
24     24   13519      13466.854    13519    13519      13519      13519 13353.226 13581.190
25     25   14481      14427.910    14481    14481      14481      14481 14320.628 14521.419
26     26   15291      15320.135    15291    15291      15291      15291 15226.067 15426.959
27     27   15998      16022.090    15998    15998      15998      15998 15896.937 16168.287
28     28   16679      16765.644    16679    16679      16679      16679 16692.131 16865.637
29     29   17786      17862.530    17786    17786      17786      17786 17739.124 17980.584
30     30   18994      19043.169    18994    18994      18994      18994 18852.610 19234.579
31     31   20465      20189.853    20465    20465      20465      20465 19859.251 20620.668
32     32   21602      20988.209    21602    21602      21602      21602 20351.183 21624.525
33     33   22154      21700.845    22154    22154      22154      22154 21023.693 22301.497
34     34   21878      22219.319    21878    21878      21878      21878 21606.877 22748.624
35     35   22371      23242.766    22371    22371      22371      22371 22669.286 23662.722
36     36   23035      24159.912    23035    23035      23035      23035 23484.224 24608.285
37     37   23742      25106.399    23742    23742      23742      23742 24118.976 25799.705
38     38   24156      26167.685    24156    24156      24156      24156 24930.913 26878.822
39     39   24931      26987.205    24931    24931      24931      24931 26018.809 27606.951
40     40   25755      27955.215    25755    25755      25755      25755 26837.465 28922.022
41     41   26943      29893.142    26943    26943      26943      26943 28006.035 31343.983
42     42   27449      30823.259    27449    27449      27449      27449 28701.778 32359.189
43     43   28348      31702.689    28348    28348      28348      28348 29878.087 33220.784
44     44   28855      32483.415    28855    28855      28855      28855 30745.163 34220.393
   lower90.ct upper90.ct
1    2173.964   2298.060
2    2283.028   2414.557
3    2435.333   2542.348
4    2556.874   2647.293
5    2736.046   2809.979
6    2899.836   2978.642
7    3089.359   3217.507
8    3304.561   3385.993
9    3588.843   3696.634
10   3968.748   4094.679
11   4329.679   4463.580
12   4696.765   4826.078
13   5064.773   5216.834
14   5563.610   5744.943
15   6143.835   6331.609
16   6657.459   6787.396
17   7302.789   7444.224
18   7956.286   8118.551
19   8706.756   8926.264
20   9727.572   9947.972
21  10819.970  11028.378
22  11919.011  12136.687
23  12599.613  12826.147
24  13357.489  13556.499
25  14353.715  14503.449
26  15236.349  15424.620
27  15913.232  16156.669
28  16698.753  16844.315
29  17747.301  17953.343
30  18864.751  19212.392
31  19871.492  20439.302
32  20438.841  21439.440
33  21059.926  22234.386
34  21694.367  22670.574
35  22731.607  23629.204
36  23584.525  24602.245
37  24292.515  25748.246
38  25455.209  26802.317
39  26140.335  27518.249
40  27139.533  28654.460
41  28595.919  31210.174
42  29396.076  32288.427
43  30289.826  32912.722
44  30989.411  33889.291

 続いてAbadie et al.(2014)のFigure 3を再現してみよう。これは処置効果(ATT)の図であり、plot()関数で作成できる。ここでのATTは実際の西ドイツの一人当たりGDPと架空の西ドイツの一人当たりGDPのギャップだから、type = "gap"を追加する。また、論文には信頼区間も表示されていないのでplot.ci = "none"を追加する(省略するとATTの95%信頼区間も表示される)。また、下段の棒グラフを消すためにshow.countFALSEにする。

plot(scm_fit, type = "gap", plot.ci = "none", show.count = FALSE)

 散布図の形式になっているが、これを折れ線グラフにするためにはconnected = TRUEを追加し、点を削除するためにshow.point = FALSEを別途指定する必要がある。

plot(scm_fit, type = "gap", plot.ci = "none", 
     connected = TRUE, show.point = FALSE, show.count = FALSE)

Abadie et al.(2014)のFigure 3の再現

 再現が目的であれば、上記の図で完成であるが、論文に掲載するのが目的であればやはり処置効果の統計的有意性も検討した方が良いだろう。先ほどのコードからplot.ci引数を省略すると95%信頼区間が出力される。

# plot(scm_fit, type = "gap", connected = TRUE, show.point = FALSE, show.count = FALSE)
plot(scm_fit, type = "gap", show.count = FALSE)

 統一直後、若干のGDP上昇が確認できたが、1993年からは一貫して負のATTが観察され、これは統計的に有意である。また、経済効果というのは累積で考える必要があることを考慮すると、ドイツ統一は少なくとも西ドイツのGDPの面では負の効果をもたらしたと評価できよう(他の側面ではプラスになっていた可能性もあるが、少なくとも今回の応答変数では負の因果効果があった)。

 以上の図を自分でゼロベースで作成したい時にはestimand()関数(第2引数として"att"を指定)でATTの推定値を取得できる。

estimand(scm_fit, "att") # scm_fit$est.att でも可能だが、マトリックス型になる
   event.time     estimate        se        ci.lo         ci.hi n_cells   vartype
1         -29    48.160634  43.23376   -36.575984  1.328973e+02       1 bootstrap
2         -28    50.210348  40.63266   -29.428202  1.298489e+02       1 bootstrap
3         -27    44.896091  31.36454   -16.577285  1.063695e+02       1 bootstrap
4         -26    14.893249  30.90367   -45.676825  7.546332e+01       1 bootstrap
5         -25    33.335172  21.38781    -8.584161  7.525451e+01       1 bootstrap
6         -24    71.474442  25.27785    21.930763  1.210181e+02       1 bootstrap
7         -23    21.109503  36.92322   -51.258683  9.347769e+01       1 bootstrap
8         -22  -100.436622  28.30085  -155.905265 -4.496798e+01       1 bootstrap
9         -21   -72.429798  34.73328  -140.505773 -4.353823e+00       1 bootstrap
10        -20   -34.538184  44.38665  -121.534418  5.245805e+01       1 bootstrap
11        -19   -45.479765  39.18623  -122.283365  3.132383e+01       1 bootstrap
12        -18   -74.603988  41.18150  -155.318240  6.110265e+00       1 bootstrap
13        -17   -88.754800  45.25713  -177.457146 -5.245379e-02       1 bootstrap
14        -16  -107.823872  51.72750  -209.207901 -6.439842e+00       1 bootstrap
15        -15  -178.342977  55.25348  -286.637802 -7.004815e+01       1 bootstrap
16        -14  -132.458271  46.87118  -224.324092 -4.059245e+01       1 bootstrap
17        -13     2.181684  50.17288   -96.155355  1.005187e+02       1 bootstrap
18        -12    60.594123  51.78459   -40.901809  1.620901e+02       1 bootstrap
19        -11   128.860001  64.83581     1.784149  2.559359e+02       1 bootstrap
20        -10   245.469564  68.19464   111.810533  3.791286e+02       1 bootstrap
21         -9   171.072311  66.10860    41.501833  3.006428e+02       1 bootstrap
22         -8    93.733831  66.93648   -37.459255  2.249269e+02       1 bootstrap
23         -7    56.880591  85.46877  -110.635126  2.243963e+02       1 bootstrap
24         -6    59.801479  68.58406   -74.620809  1.942238e+02       1 bootstrap
25         -5    59.138430  49.55901   -37.995440  1.562723e+02       1 bootstrap
26         -4   -36.511006  57.41571  -149.043738  7.602172e+01       1 bootstrap
27         -3   -31.686400  70.05097  -168.983787  1.056110e+02       1 bootstrap
28         -2  -104.976161  48.08220  -199.215532 -1.073679e+01       1 bootstrap
29         -1   -89.146283  58.22066  -203.256683  2.496412e+01       1 bootstrap
30          0   -64.623325 105.24023  -270.890378  1.416437e+02       1 bootstrap
31          1   261.997041 189.99318  -110.382743  6.343768e+02       1 bootstrap
32          2   623.808321 309.51475    17.170554  1.230446e+03       1 bootstrap
33          3   468.317349 317.48948  -153.950605  1.090585e+03       1 bootstrap
34          4  -341.450330 285.15286  -900.339668  2.174390e+02       1 bootstrap
35          5  -873.160801 272.37852 -1407.012890 -3.393087e+02       1 bootstrap
36          6 -1117.971269 313.67782 -1732.768499 -5.031740e+02       1 bootstrap
37          7 -1365.121079 438.53141 -2224.626843 -5.056153e+02       1 bootstrap
38          8 -2050.246857 454.90117 -2941.836771 -1.158657e+03       1 bootstrap
39          9 -2069.636893 390.96789 -2835.919881 -1.303354e+03       1 bootstrap
40         10 -2250.510109 499.70914 -3229.922033 -1.271098e+03       1 bootstrap
41         11 -3075.214749 850.19467 -4741.565681 -1.408864e+03       1 bootstrap
42         12 -3480.070065 911.79565 -5267.156700 -1.692983e+03       1 bootstrap
43         13 -3515.934625 869.39594 -5219.919356 -1.811950e+03       1 bootstrap
44         14 -3833.937898 888.12659 -5574.634036 -2.093242e+03       1 bootstrap

脚注

  1. この2つの変数は予めcharacter型、またはfactor型に変換しておこう。feols()の場合、numeric型でもダミー変数に変換し、問題なく推定できるが、固定効果モデルにおけるユニットや時間の変数はcharacter型、またはfactor型変数に変換してから使う習慣を付けておいた方が良い。なぜなら、固定効果モデルの推定ができる他のパッケージではcharacter型、factor型が強制されるケースも多いからだ。↩︎

  2. Abadie, Alberto, Susan Athey, Guido W Imbens, Jeffrey M Wooldridge. 2023. “When Should You Adjust Standard Errors for Clustering?,” The Quarterly Journal of Economics, 138(1): 1-35.↩︎

  3. Hassell, Hans J. G. and John B. Holbein. 2025. “Navigating Potential Pitfalls in Difference-in-Differences Designs: Reconciling Conflicting Findings on Mass Shootings’ Effect on Electoral Outcomes.” American Political Science Review, 119(1): 240-60.↩︎

  4. {fixest}のfeols()でSun & Abraham(2021)の実行も可能である。詳細は{fixest}パッケージのsunab()関数のヘルプを参照されたい。↩︎

  5. 本来、{gsynth}という独立したパッケージであったが、今は{fect}に統合されている。↩︎

  6. 統一の因果効果は翌年以降から見られる可能性もあるので、1991以上の方が適切かもしれない↩︎