Syleir’s note

2020.4.1より統計検定やE資格の勉強の進捗を報告しています。統計検定準1級、E資格、G検定取得しました!当ブログへのリンクはご自由にどうぞ。

MENU

【傾向スコアマッチング】Optimized pair matching(最適ペアマッチング)のPythonでの実装【Part2】

はじめに

前章では最適ペアマッチングの利点、活用方法について解説しました。
そこで今回は実際の考え方・実装を解説します。

二部グラフマッチング

今回の最適ペアマッチングは二部マッチングという概念に包含されます。
二部マッチングは2つの異なる集合から成るグラフ(二部グラフ)において、辺を選んで、それぞれの頂点が高々1つの辺に関連付けられるような集合の組み合わせを見つける問題を指します。
より平たく言うと、2つの集合から重複がないようにペアを作っていく問題です。
実社会でもよく使われています。
例えば、

  1. 男性の集合、女性の集合から重複がないようにペアを作るマッチングアプリ
  2. 会社の集合、就活生の集合から重複がないようにペアを作る就職のマッチング
  3. 工場の集合、リソース(人間や原材料)の集合から重複がないようにペアを作るリソースの割り当て

などがあるでしょうか。

最小重み最大二部マッチング

そして今回の最適ペアマッチングを求める問題は二部マッチングの問題の中でも最小重み最大マッチングという問題に帰結されます。
最小重みマッチングは、二部グラフ内の辺に非負の重み(コストやコストと見なされる値)が割り当てられた場合に、それらの辺を選んでマッチングを形成する際の最小総重みをとるマッチングを求める問題です。
最小重みマッチングの場合は「選ばない」という選択肢が全ての辺の選択の中で自明に一番コストが低く、ナンセンスな問題になってしまいますから、最大マッチング(=できるだけ多くの辺を選ぶ)という制約を追加します。
つまり、最小重み最大二部マッチングは、「辺に重みが与えられている二部グラフにおいて、頂点の重複がないようにできるだけ多くの辺を選び、その選んだ辺のコストを最小化する」というような問題になります。

具体的な例を挙げましょう。

このような二部グラフに対して辺に重みが与えられているものを考えます。
最小重み最大マッチングとして選ばれるのは

このように 1 - 1 , 2 - 0 の合計重み5の組み合わせになります。
これを解くアルゴリズムは最小費用流を用いた方法や、Hungarian法などがあり解くことができます。

ということで、最適ペアマッチングをこれに帰着すれば良いことがわかりました。

最適ペアマッチングを最小重み最大二部マッチングに帰着する

さて、前章で用いた例を使います。

このような対照群:6人、治療群:4人に対して、傾向スコアを計算し与えられている例を考えていました。
これをグラフ理論的に頂点と辺に直すとこのようになるのでした。

今は頂点それぞれがPSの情報を持っているわけですが、最小重み最大二部マッチング問題はに非負の重み(コストやコストと見なされる値)が割り当てられていることが必要になるわけです。
最適マッチングの目指すところは、「全ての頂点の組み合わせの中からPSの差の合計が最も小さくなるように頂点のペアを選ぶ」ことでしたから、これを
「全ての頂点の組み合わせから頂点のペアを選ぶ」→「頂点が被らないように辺を選ぶ」
「頂点のPSの差」→「辺のコスト:その辺が結ぶ頂点同士のPSの差の絶対値
と言い換えてあげることにより、
「全ての頂点の組み合わせの中からPSの差の合計が最も小さくなるように頂点のペアを選ぶ」
→「できるだけ多くの辺を選ぶマッチングのうち、コスト(=PSの差の絶対値)の合計が最も小さくなるように辺を選ぶ」
と適切に言い換えることができますから、これで最適ペアマッチングを最小重み最大二部マッチングに帰着することができました。

つまり、

このように辺に重みを与えて辺を選ぶ問題と帰着することで最小重み最大マッチング問題に帰着することができるのです。

最小重み最大二部マッチング問題を解く

これを解くアルゴリズムは最小費用流を用いた方法や、Hungarian法などがあります。
二部グラフのマッチング問題は競技プログラミングの文脈でも頻出です。下記を参照ください。
qiita.com

しかし、Pythonの便利なところはなんでしたか?そう、豊富なライブラリによって実装力がなくてもライブラリで殴ることができることです。
今回はNetworkXというライブラリを使いましょう。

NetworkXとは?

NetworkXは、Pythonでグラフを作成、操作、可視化するためのライブラリです。
余談ですが、AtCoderでも使用可能です。ライブラリの読み込みが遅く、ナイーブな実装に処理速度で負けるためあまり使われていません。
今回は処理速度は問わないので便利なものは使っていきましょう。

実装

#ライブラリの読み込み
from scipy.spatial.distance import cdist
import numpy as np
import networkx as nx

ライブラリを読み込みます。今回、networkxの他に辺のコストを効率よく求めるためのライブラリとしてscipyと行列はnp.arrayとして持つ必要があるのでnumpyを使います。

# コントロール群と処置群の傾向スコアがそれぞれ与えられていると仮定
control_scores = np.array([0.2, 0.25, 0.3, 0.6, 0.65, 0.7])
treatment_scores = np.array([0.2, 0.65, 0.8, 0.85])

上で使った例と同じ数字を使っています。同じ結果が出るかを確かめましょう。

# コントロール群と処置群の傾向スコア間の距離行列を計算
distance_matrix = cdist(control_scores.reshape(-1, 1), treatment_scores.reshape(-1, 1))

ここで、distance_matrix は以下のようになっています。

これは上のグラフで求めた辺のコスト(=PSの差の絶対値)に対応しています。
わかりやすくするとこうです。


これでdistance_matrixに辺のコストの情報を持たせることができました。

#グラフを作る前に各群のサイズを求めておく 
control_size = len(control_scores)
treatment_size = len(treatment_scores)
#グラフを作る
graph = nx.Graph()
#距離行列の値を重みとしてグラフにエッジを追加
for i in range(control_size):
    for j in range(treatment_size):
        graph.add_edge(f"C{i}", f"T{j}", weight=distance_matrix[i, j])
# 最小重み完全マッチングを求める
matching = nx.min_weight_matching(graph)

NetworkXではグラフを用意してあげれば、min_weight_matchingで最小重みマッチングを作ってくれます。最強ですね。

# マッチング結果を表示
print(matching)

>>出力:{('C5', 'T3'), ('T0', 'C0'), ('T1', 'C3'), ('C4', 'T2')}
となり、正しい出力が得られていることがわかります。とは言ってもややわかりにくいのでいい感じに可視化をします。

可視化

import matplotlib.pyplot as plt

# コントロール群と処置群のノードを指定
control_nodes = ['C0', 'C1', 'C2', 'C3', 'C4', 'C5']
treatment_nodes = ['T0', 'T1', 'T2', 'T3']

# 二部グラフを作成
G = nx.Graph()

# ノードを追加(二部グラフにおけるノードの属性を指定)
G.add_nodes_from(control_nodes, bipartite=0)  # bipartite=0 はコントロール群を示す
G.add_nodes_from(treatment_nodes, bipartite=1)  # bipartite=1 は処置群を示す

# マッチングをエッジとして追加
G.add_edges_from(matching)

# 二部グラフのレイアウトを取得(ノードを正順に配置)
layout = {node: (1, i) for i, node in enumerate(reversed(control_nodes))}
layout.update({node: (2, i) for i, node in enumerate(reversed(treatment_nodes))})

# 二部グラフを描画
nx.draw_networkx_nodes(G, pos=layout, nodelist=control_nodes, node_color='b', node_size=500, alpha=0.8)
nx.draw_networkx_nodes(G, pos=layout, nodelist=treatment_nodes, node_color='g', node_size=500, alpha=0.8)
nx.draw_networkx_edges(G, pos=layout, edgelist=matching, width=2, alpha=0.5, edge_color='r')

# ノードのラベルを描画
node_labels = {node: node for node in G.nodes}
nx.draw_networkx_labels(G, pos=layout, labels=node_labels)

plt.title("Optimized Matching Result")
plt.show()

図示だけなので細かいテクニックは省略します。

結果は以下のようになります。

当初の結果と比較しても正しい結果が得られていることがわかります。

おわりに

いかがでしたか?
いつか解説記事のおわりにいかがでしたか?を書いてみたいと思っていました。
ややニッチな内容でしたが、理解の助けになれば幸いです。

references

NetworkXについて興味を持った方、より詳しい実装についてはこちらがおすすめです。

洋書ですが、因果推論のPythonでの実装について触れている本です。いつか和訳になるんでしょうか。ならない気がします。

【傾向スコアマッチング】最近隣法マッチングと最適ペアマッチングの手順、比較について解説する【Part1】

はじめに

今回の連続記事は傾向スコアマッチングにおいて、Pythonでの最適ペアマッチングの実装がインターネット上になかなか落ちていなかったので解説しようという試みです。これを読むことでPythonでの最適ペアマッチングの実装がわかります(需要ある?)
最初からアルゴリズムを載せても何がなんだかという感じだと思うので二部構成にして最初はとっつきやすい感じにしてあります。

統計的因果推論において、マッチングを利用することでバイアスを減らそうと計画することがあります。
RCTが行えないような観察研究において、アウトカムに影響を与えるような交絡因子が近い組をマッチングすることで、原因が結果に及ぼす因果関係をより明確にする意図があります。
マッチングにおいては、色々なマッチング方法があります。たとえば、ランダムフォレストやベイズ的な手法、これから紹介する傾向スコアマッチングなどがあります。

傾向スコアマッチングとは?

ランダム化は観測された共変量及び、観測されない共変量のバランスを取ることで因果関係を示すことができるという意味で他の研究デザインよりも優位性があると過去記事で書きました。詳しくはこの記事をご参照ください。
syleir.hatenablog.com
ランダム化比較試験(RCT)は素晴らしい研究デザインですが、様々な制約によりRCTができないことがあります。
そのような際に傾向スコアによるマッチングを用い、観測される共変量のバランスを取り、交絡による影響を減らすことでよりRCTに近づけた状態でバイアスの影響を減らすデザインを取ります。
ざっくり言うと、傾向スコアは観測されている共変量から計算される処置群への割り当てられる確率なので、これが似た値のペアを対照群と処置群で作ることで、共変量の影響を減らすということです。

傾向スコアマッチングの種類

傾向スコアマッチングにはマッチング方法が複数あります。
RではMatchItなどのライブラリを用いてマッチングを行う方法が最もよく使われると思います。
具体的に列挙すると

  • 最近隣法マッチング(nearest neighbor matching)
  • 最適ペアマッチング(optimal pair matching)
  • 遺伝的マッチング(genetic matching)
  • 厳密マッチング(exact matching)
  • 純化厳密マッチング(coarsened exact matching)
  • 層化(subclassification)
  • 最適フルマッチング(optimal full matching)

などがあります。

最適ペアマッチングとは?

最近隣法マッチングとの対比がわかりやすいので比べながら説明していきましょう。

まず、最近隣法マッチングについて解説します。
最近隣法マッチングは、以下の手順で行います。
①それぞれの群を小さい順(または大きい順)に並び替え(ソート)をします。
②サイズの小さい群から順番に個体を選択し、もう一方の群から「距離」が最も近いものを抽出してマッチングさせます。

たとえば以下のような傾向スコア(propensity score:PS)を持つ2群を考えます。ここでは「距離」については単純にPSの絶対値の差と考えます。

図が煩雑なので、このようなマッチング理論ではグラフ理論を利用し単純なグラフで書くことがあります。下記のようにノード(丸)とエッジ(線)で単純化します。

右側の0番の個体から傾向スコアが近い順で順番にマッチングさせていくと以下のようになります。

非復元抽出と復元抽出

さて、このマッチングは本当に「良い」マッチングと言えるでしょうか。
直感的には右の群の3番と左の群の3番がマッチングしているのが気持ち悪いような気がします。
マッチングの基本理念は「交絡因子が近い組をマッチングすることで、原因が結果に及ぼす因果関係をより明確にする」ことでしたから、PSが大きく違う個体同士のマッチングは「良いマッチング」とはなりません。
これを解決するのが復元抽出によるマッチングです。
復元抽出は同じノードの2回以上の使用を許すことです。復元抽出を許して上のように最近隣法マッチングを行うと以下のようになります。

このように、左の群の5番が2回使われることでより良いマッチングになっていることがわかります。PSが0.25違うところから0.15の差まで減らせていることがわかります。

最適マッチング

復元抽出による最近隣法マッチングは万能でしょうか?
復元抽出を行うことで、使用しているデータが減る(情報量が減る)、同じデータを複数使うことでアウトカムが偏るなどの欠点があります。
データの分布次第では同じ個体がかなりの多くの回数マッチングさせられて結果自体が歪んでしまうことだって考えられます。
そのような際に使うのが最適ペアマッチング(Optimal Pair Matching)です。

最適マッチングの方法

最適マッチングは全ての組み合わせの中からPSの差の合計が最も小さくなるように選びます。

このように全ての組み合わせを考え、その中で最もPSの差の合計が最も小さくなるように選ぶと以下のようになります。

先ほどよりも「良い」マッチングになりつつも最大限の情報量を活用していることがわかります。
難点はコンピューターの計算量が増えること、実装が難しいことです。データの数が増えれば増えるほど計算量が増え、また最近隣法を選択した時のデータの偏りも減るので、一般的にはデータ数が少ないマッチングの際に使われます。
どのように実装、アルゴリズムを考慮するかは次章で解説します。

References

2023年1月に出た、傾向スコアにのみ重点的に触れた貴重な本です。基本的なところから解説してくれていてかなり読みやすいです。
今シーズンのベストバイだと思っています。

Rでの実装を載せながらかなり詳細に解説してくれています。
サンプルデータもありますし、Rが使えるのであれば相当おすすめです。

Part2はこちら

次回実装編です。Pythonを用いて最適ペアマッチングを実装していきます。
syleir.hatenablog.com

ランダム化比較試験(RCT)はなぜエビデンスレベルが高いのか?

はじめに

投稿が滞っていてすみません。今日はタイトルの通りで、
「ランダム化比較試験(以下RCT)はなぜエビデンスレベルが高いのか?」
について話していきたいと思います。

この質問、意外と難しくないですか?

 

医学研究、疫学研究のみならず、近年では社会科学などでもRCTが取り入れられるようになってきました。

因果推論の文脈においてもRCTはよく使われます。

 

病気の治療法や診断というのは、1990年くらいまでは経験的に行われることが多かったです。この病気にはこれを使うと効くことが多い、この病気にはこのような症状が出ることが多い、などから診断。治療が行われてきました。

しかし、医学、保健学分野ではここ2−30年程度でEBM(Evidence based medicine)という言葉が人口に膾炙するようになりました。

エビデンス」というものを用いて、確度の高く、均一な治療を行いたいという動機のもとにEBMが流行るようになりました。

この文脈で、皆さんも過去に一度は以下のような古典的なピラミッドを見たことがあるでしょう。(厚生労働省より引用)

エビデンスレベルの古典的ピラミッド

ステマティックレビュー、メタアナリシスを筆頭として、ランダム化比較試験、コホート研究、症例対照研究、症例報告と続くピラミッドです。

ステマティックレビューやメタアナリシスは複数の研究成果をまとめたものなので、このピラミッドの頂点に立つのはなんだかずるい気がしますが、これらも研究デザインの一つではあるので仕方ないですね。

今日はこのピラミッドのうち、システマティックレビューとメタアナリシスを除いた、つまり単独の研究デザインにおいて、なぜRCTがその頂点に立っているのかについて話したいと思います。

学生教育においても、順番を覚えさせるだけでこの分野の学習は終わってしまいます。この個々の順番に関してはなんの意味もありません。

質の良い症例対照研究が質の悪いコホート研究より良いエビデンスを残すことだってザラにあります。また上に挙げたような研究デザインに当てはまらない研究デザインのものもたくさん出てきています。

そんな時に順番だけ覚えていてもなんの役にも立ちません。エビデンスが強くなるためには何をしたら良いか、そのデザインのどこがエビデンスレベルを押し上げているのかを理解していれば、今読んでいる研究はエビデンス足りうるかの判断に使えるようになるでしょう。

ということで、今回RCTはなぜエビデンスレベルが高いのかという文章を書いていきたいと思います。

 

目次

 

RCTの概要

ランダム化比較試験は、因果推論における重要なツールです。

医学や臨床研究などの分野で使用される実験デザインの一つで、治療効果や介入の効果を評価し、異なる治療グループ間で結果を比較するための方法になります。

  1. 研究の対象者をランダムに2群(デザインによっては2群以上)に分ける
  2. 片方を介入群、もう片方を対照群とする
  3. 介入群にのみ治療(介入)を行う
  4. アウトカム(生存率など介入の指標)の比較を行う

といった手順により治療の効果があるかを検討します。

RCTの概要

前向きコホートとの比較

RCTと同じような研究デザインとして、前向きコホート研究があります。概要を下に図示します。

前向きコホート研究の概要

RCTと前向きコホート研究の大きく違うところは患者の割り当てです。
RCTが完全にランダムに治療群と対照群を割り当てるのに対し、前向きコホート研究では医師が治療適応を考えて治療群として割り付けます。

 

統計的因果推論のモチベーションを振り返る

かつて書いた記事で、統計的因果推論の目標は、知りたい因果関係以外の関連をすべて無くして、知りたい因果関係があるかを調べることと書きました。

因果推論が難しい理由は、知りたい因果関係以外の関連をなくすことが難しいからです。

詳しいことはこの記事を参考にしてください。

syleir.hatenablog.com

コホート研究では選択バイアスを対処するのが難しく、知りたい因果関係を知るのに支障があります。

選択バイアス(selection bias)とは?

選択バイアスとは、分析の仮定でサンプリングやデータの選択が生じる際に、その仮定で生じる結果の偏りのことです。この選択バイアスは、データを選ぶ時に、全体の人口を代表する集団を取って来れないことにより生じます。

具体例で考えましょう。

1.年齢による選択バイアス

ある疾患を持つ患者群に対して治療Aをやるか、治療Aをやらないかで前向きコホート研究をしたいです。ただし治療Aは適応が75歳以上に適応されています。この時、治療群は75歳以上しかいないのに対し、対照群は全人口から選択されているため、治療群の方が成績が悪くなってしまいます。つまり治療成績が過小評価されます。

2.自己選択による選択バイアス

ある疾患を持つ患者群に対して治療Aをやるか、治療Aをやらないかで前向きコホート研究をしたいです。ただし治療Aをやるかどうかは患者の意思により決定されます。この時、治療群は治療意欲が高い患者ばかりが集められるので、裏で健康に良い運動や食事などを取っている可能性があります。これにより治療成績が過大評価されます。

3.適応による選択バイアス

ある疾患を持つ患者群に対して治療Aをやるか、治療Aをやらないかで前向きコホート研究をしたいです。ただし、この疾患には標準治療が確立されており、かなりの奏功率で症状が改善してしまうことが知られています。医師は症状が治らない患者に対して治療Aでの治療を試みています。この時、治療Aをされる患者は原病のコントロールが悪く、対照群より状態が悪い可能性があります。これにより治療成績が過小評価されます。

具体例を出そうと思えば他にもたくさんありますが、このように、選択に意思が介在すると結果が歪んでいることがわかります。これを解決するのがランダム化になります。

ランダム化がなければ結果の解釈が困難となります。

ランダム化とは?

ランダム化とは、治療を受けるか受けないか(あるいは他の治療を受けるか)を等しい確率で割り当てることです。実運用では臨床研究に参加する際に擬似乱数を使用し割り当てられます。

ランダム化は観測された共変量のバランスをとる

確率1/2のコイントスをやる時、偶然結果が偏ることはありますが、何回も試行を重ねれば、均等な分布になっていくことは経験の通りです。結果に影響を与える共変量はたくさんありますが、(上の例では年齢、治療意欲、原病のコントロール度合い)それぞれも母集団を集めて割り振れば均一に調整されることがわかります。上の選択バイアスが生じる際には、なんらかの意思が介在していますが、コイントスは意思が介在しない(選択をしていない)ため、選択バイアスは生じません。治療法の選択において、患者属性を考慮しないが故に同じような背景の患者を割り当てることができます。                      

ランダム化は観測されない共変量のバランスもとる

ランダム化は共変量を調整しますが、観測された共変量の調整は傾向スコア(propensity score)の導入などでコホート研究においても実は可能です。しかし、傾向スコアにも限界があり、それはいつか記事にしたいと思います。

もっとも大きい限界としては、傾向スコアによる選択バイアスの調整は、観測されている共変量の調整しかできないことです。傾向スコアは取れているデータを用いて共変量の調整を行うため、取れていないデータが結果に影響を与えている場合は選択バイアスが残存します。

しかし、ランダム化においては、原理的に観測されている、されていないに関わらず、同じように分布を均一化するため、観測されていない共変量に関しても選択バイアスの影響をなくすことができます。コホート研究においては共変量による選択バイアスの影響が避けられないのに対し、RCTでは選択バイアスの影響を最小限にできるのが圧倒的に有利です。

RCTの限界

こう見ると、RCTはいいことばかりのようにも見えますが、限界はあります。

倫理的限界

たとえば、末期がん患者に関する疼痛コントロールの影響を知りたい研究デザインの時、対照群は末期がん患者に疼痛コントロールを行わないことになりますが、これは倫理的に許されません。RCTは、明らかに研究患者に害をなさない研究デザインとして組み立てる必要があります。知りたいクリニカルクエスチョンが全てRCTできるとは限りません。

金銭的限界

RCTは治療をし、フォロー、統計解析するに至るまで、どこにおいても継続的にお金がかかります。RCTにおいては患者の治療費を研究費として負担するため、研究費の問題があり、RCTができない例もあるでしょう。

症例数としての限界

希少疾患においては、コイントスが収束するまで症例数を集めることができず、必要症例数を登録できない可能性があります。

他にもRCTには色々な制約があります。RCTは良いデザインですが、多角的に物事を見ることが必要です。

全てのRCTが良い研究ではない

ここまでの話を聞いて、盲目的にRCTだからエビデンスレベルが高いと判断してはいけません。RCTが良いのは、最初に出したこの図において、

ランダム化によって選択バイアスが減らせるから、という点のみです。

対照群の扱い方、治療群の治療方法、ランダム化によって背景因子はちゃんと調整されているか、統計解析の方法、アウトカムは適切かなど、良い研究とされるためにはもっと色々な情報があります。論文を読む際はMethodのところもちゃんと読んで批判的吟味をすることが重要です。

結論

RCTはなぜエビデンスレベルが高いのか、
それは、

バイアスを減らせるデザインだから

です。しかし、RCTというだけで盲目的に信じて良いわけでもありません。結局よく考えることが一番大事ということですね。では。

 

参考文献

最もおすすめです。この本から引っ張ってきた文章も多くあります。観察研究の歴史、RCTに関して具体例を用いながら詳細に記載してくれています。邦訳ですが、かなり上手に訳してくれているのでわかりやすいのもGood point!

 

RCTの読み物として非常に面白いです。社会科学分野でもかなり使われるようになってきています。おすすめです。

 

【因果と矢印】DAGの初歩を解説する【統計的因果推論】

はじめに

ここ最近、統計的因果推論の文脈で特殊なモデルとしてITSを扱ってきましたが、よくよく考えてみると初歩的なことを全く書いていないので少し整理した記事を書きます。今日はDAGという、因果推論の初歩、矢印で因果関係を表現する方法を初心者向けに解説します。

DAGとはなにか

有向非巡回グラフ(DAG)の定義と特徴

DAG:directed acyclic graph 有向非巡回グラフと言います。
DAGは因果推論の文脈でのみ使われるという訳ではなく、一般的なグラフ理論用語でもあります。因果推論のグラフィカルモデルはグラフ理論から派生した理論なので、例えば競技プログラミングなどで、DAGという言葉を聞いたことがある方は多いのではないでしょうか。
以下、本記事では「一般的なグラフ理論用語で使われるDAG」という文脈で「DAG」を用いる時は必ずこの旨を明記するようにし、断りなく「DAG」という用語を使うときは「因果推論の文脈でのDAG」という意味で書きます。DAGには以下のような特徴があります。

DAGの特徴と書き方

1. 頂点を矢印で結んで書く
2. DAGは巡回しない
3. 結ばれた矢印は変数同士の直接の因果関係を表す


1. 頂点を矢印で結んで書く
因果推論の文脈では、変数のことを頂点(あるいはnode)と言います。
そして一般的なグラフ理論では頂点を辺(edge)で結んでいきます。辺には有向辺と無向辺がありますが、グラフ理論において、DAGは、無向辺(線分)ではなく、有向辺(矢印)で結んでいきます。

2. DAGは循環、巡回しない
グラフ理論においてDAGは循環しないという原則があります。例えば、下図のような巡回するグラフはDAGではありません。

DAGではない有向グラフ

3. 結ばれた矢印は変数同士の直接の因果関係を表す
今までの2つの定義は一般的なグラフ理論におけるDAGと同様です。これを因果推論に拡張します。
因果推論におけるDAGでは、頂点は変数を表し、X→Yという矢印があったとき、それは変数Xから変数Yに直接の影響があることを意味します。
風が吹けば桶屋が儲かる」という言葉がありますが、あれはたくさんの間接的な因果関係を介在しているので風が吹く→桶屋が儲かるというDAGを書いてはいけません。

因果推論における因果グラフの役割

DAGを書くことで、検証したい因果関係に対して、いろんな変数が介在しているとき、統計モデルを作る上で、考慮すべき変数と、無視すべき変数を機械的に整理することができます。

経路が開いている、閉じているとは?


このようなDAGをまず見てみましょう。このDAGを使って、XとYの直接の因果関係を見たいとします。ただ、上で見た通り、矢印で繋がっているところはそれぞれ、直接の関連がありますから、例えば、「AとX」「AとY」がこの図では直接の関連があるということになります。そうすると、X→Yという直接の経路以外にも、Aを介した、「X←A→Y」という経路がXとYの関係に影響を与えている可能性があります。ただし、直接の関連があるもの同士が繋がっているからと言って、その相方同士に影響を及ぼしているとは限りません。あなたの知り合い2人を連想したとき、その2人がそれぞれ知り合いであることもあれば、全く繋がりがないこともあるでしょう。そんな感じです(?)

DAGにおいて、経路が開いているということは、その経路で関連が存在することを意味します。反対に、閉じている経路に関してはその経路で関連が存在しないということを意味します。

因果推論においては、知りたい因果関係以外の関連をすべて無くして、知りたい因果関係があるかを調べたいというモチベーションがあります。知りたい因果関係以外の開いている経路を把握することがまずモチベーションになります。つまり、この図では、「X→Y」以外に「X←A→Y」、「X→C→Y」、「 X→B←Y」という経路があるが、まずこれらのうちどれが開いている経路なのかを特定することがやりたいことになります。

矢印の向きで関連があるかを見る

実は、DAGを利用することで、矢印の向きを見るだけで、数学的な処理をすることなく、視覚的に開いている、閉じているが判断できます。これがDAGを利用するメリットです。ただ、最初は説明があった方が良いと思うので具体例を引きながら説明していきます。

共通の原因があるとそれを介した経路は開いている

共通原因(common cause)としてよくある例は、毛髪量と年収の例です。毛髪量が少ないほど年収が高いというデータがあったとします。直感的には散髪をして毛髪量を減らしたら年収が増える、植毛をしたら年収が減る、ということはなさそうです。ということはこれらには因果関係はなさそうです。これの「妥当な」説明としては、裏に「年齢」という共通原因があり、これがそれぞれに影響を与えているというようなことができます。これをDAGで書くとこのようになります。

共通原因を介した経路、すなわち「毛髪量←年齢→年収」という経路がありますが、毛髪量と年齢は関連があるので、この経路は「開いている」という言い方をすることができます。
このような年齢のような、共通の原因をもたらす変数を「交絡因子」と言います。

共通の結果があるとそれを介した経路は閉じている

共通効果(common effect)の具体的な例として、肥満を挙げます。肥満は遺伝的要因と、運動不足などの環境要因の相乗効果で生じていると考えられます。

しかし、一般的には遺伝的要因と環境要因は関係がないので、例えば、より運動量が減ったからと言って、遺伝的要因に影響を及ぼす訳ではありません。
共通の結果を介した経路、すなわち「遺伝的要因←肥満→環境的要因」という経路がありますが、遺伝的要因と環境要因は関連がないので、この経路は「閉じている」という言い方をすることができます。
このような肥満のような、共通の結果となるような変数を「合流点(collider)」と言います。

中間因子があるとその経路は開いている

一般に、糖尿病があると死亡リスクは高くなると考えられます。糖尿病と死亡リスクの関係を知りたいです。糖尿病というのは糖の代謝異常などにより高血糖が生じるような病態ですが、高血糖だけで死ぬことは(糖尿病性ケトアシドーシスのような一部の例外疾患を除けば)死ぬことはありません。糖尿病で死亡リスクが上がるのは、糖尿病による高血糖が血管を損傷し、心筋梗塞のような心血管疾患リスクが上昇することで、死亡率の上昇につながります。
このDAGを書くと次のようになります。

このDAGでは、媒介因子である心血管疾患を介する因果経路、すなわち「糖尿病→心血管リスク→死亡率」という経路がありますが、これは糖尿病と死亡率には関連があるので、この経路は「開いている」という言い方をすることができます。
このような心血管リスクのような、因果関係を媒介するような変数を「媒介因子(mediator)」と言います。

上で見た経路3つを整理するとこのようになります。

XからYへの因果関係を見たい時、X→Y以外に「X←A→Y」、「X→C→Y」、「 X→B←Y」という経路があります。上で見たように、「開いている」経路としては「X←A→Y」、「X→C→Y」が当てはまり、「閉じている」経路としては「 X→B←Y」があることがわかります。

フロントドアパスとバックドアパス

ここで、フロントドアパスとバックドアパスという概念を導入します。フロントドア、バックドアというのは野球の変化球でも使われる概念でパスはサッカーとかバスケで使う概念でなんのスポーツなのかよくわかりませんね。
フロントドア、バックドアというのは表道、裏道という意味、パスは経路のことですから、直訳すると表道経路、裏道経路ということになりますね。
フロントドアパス、バックドアパスというのは開いている経路を区別するための概念になります。本来は厳密な定義がありますが、初歩記事なので概念のとしての導入をします。厳密な定義はバックドア基準、フロントドア基準などでググってみてください。余力があれば記事を書きます。
先ほどから多用しているDAGから開いている経路だけを抽出します。

さて、どっちがフロントドアパス(表道)っぽいでしょうか。そう、「X→C→Y」ですね。XからYへの影響を考えたいとき、この流れの向きに矢印が向いている経路を「フロントドアパス」と言い、そうでないものを表道とは反対方向に行く裏道経路、バックドアパス」と言います。

経路は開くこともできるし閉じることもできる

「ある操作」をすると経路は開くこともできるし、閉じることもできます。経路が「開いている」というのはその経路を介した関連がある、ということでしたから、経路を閉じることができれば、その経路を介した関連を無視することができます。因果推論のモチベーションは、ある変数から、ある変数に及ぼす直接影響を知りたいということでした。ですから、この経路を閉じるという操作が、因果推論のモチベーションと親和性があることはお分かりでしょうか。余計な影響を与える影響を閉じることで、直接的な影響を知ることができます。
しかし、余計な経路を閉じてしまうと、本来知りたかった関連も見れないことになってしまうし、余計な経路を開いてしまうと余計な影響が入ってしまうことになります。この「ある操作」を適切な経路に対して行っていくことが大事です。

「ある操作」って?

この経路上にある変数に対し、統計学的に処理を行うことです。例えば、その変数で層別化することや多変量回帰分析に変数を入れることです。例えば、交絡因子を介するパスは交絡因子で層別化することにより交絡因子の影響を取り除いた解析ができます。これはすなわち、交絡因子を介した関連がなくなるということであり、つまり「交絡因子を統計学的に処理したことにより開いていたパスが閉じた」、ということになります。また、合流点で層別化をしてしまうと、全く関係がなかった2つの変数があたかも関連があるように見えることがあります(合流点バイアスと言います。知らない方は検索してみてください)。有名な選択バイアスなどもこれに該当することがあります。つまり、合流点で層別化をしてしまうことは閉じていたパスを介して、関連が生じてしまうということになります。これはすなわち、「合流点での統計学的処理によって閉じていたパスが開いた」ということに他なりません。これらの例からパス上にある変数に統計学的処理を行うと閉じていたパスは開き、開いていたパスは閉じるということがわかります。

結局どの経路を閉じたらいいの?

結論から言うとバックドアパス経路を閉じたら良いです。余談ですが、ドア、閉じるというと某映画を思い出しますね。あの映画は出現する全部の開いているドアを閉じればいいので簡単ですが、DAG上ではバックドアだけしか閉じてはならない意味でさらに難しいです。
バックドアを閉じるのは交絡因子の影響を取り除きたいと言うことを考えるとわかりやすいと思うんですが、フロントドアを閉じてはいけないのは非直感的な気がします。結局知りたいのはXからYへの影響なので、中間因子を介した影響も加味したい、と言うのが直感的な説明かなと思います。

交絡因子の古典的定義

・交絡因子は原因と関連する
・交絡因子は原因がなくとも結果に関連する
・交絡因子は原因や結果の上流にある


例えばこの例では、年齢は毛髪量と言う原因と関連し、毛髪量という要素がなくても年収と関連し、毛髪量と年収の上流にあることから交絡因子ということができます。
これだけでは対処できない交絡因子が存在する事がわかってきました。

交絡の定義を拡張

→因果推論の文脈では、交絡因子は統計学的に処理する事でバックドアパスを閉じられるかどうか?と言う解釈で捉えられています。バックドアパスを形成していて、その変数を処理することでバックドアパスを閉じられるものが交絡因子となります。

例えば、X→Yの影響を知りたい上のモデルで、CはXの上流にないので、古典的な交絡因子の定義を満たしませんが、Cは「X←A→C→Y」というバックドアパスを形成しており、Cは交絡因子ということができます。直感的には交絡因子とは捉えにくいものでもDAGを書くことで交絡因子を特定しやすくなることがDAGを使う良いところです。

モデルによって何が交絡因子なのかが違う


このDAGを見てみましょう。X→Yの因果を見たい時はAは交絡因子ですが、A→Yの因果を見たい時はXは中間因子です。A→Xの因果を見たい時はYはYは合流点です。
見たい因果関係によって構築するモデルは違うし、採用しなければいけない変数も変わってくることがわかります。

予測と因果推論は違う

因果推論においては選択的に因果を見たいので統計学的処理をする、しないなどがありますが、予測においては、よっぽどそれがノイズにならない限り変数を採用します。全然頭の使い方が違うことは少し頭の片隅に置いておいても良いでしょう。
Deep Learningの予測タスクなんかはデータ量、変数量こそ正義みたいなところもあり、いい感じの特徴量を持つ変数を追加で生み出すみたいな乱暴なこともするので、そういうものとは少し違うということは知っておいても良いと思います。

おわりに

本日は因果推論の初歩、DAGで因果関係を表現することを扱いました。今後はモデル化も扱っていきたいところです。

references

Pearl流の因果推論の基礎の本です。

これは数式を使わずにPearlが因果推論のお気持ちを読み物として書いてくれています。いい本ですが、めちゃくちゃ分厚いので、数式がないわりに気合を入れて読まなきゃいけないのがややしんどいです笑

初心者向けに因果推論のやりたいことを易しく書きながらPythonでの実装も書いてくれています。やや例が冗長化かつわかりにくい気もしますが・・・
Pythonが得意な機械学習、Deep Leaningの文脈で因果推論、因果探索について触れてくれていて良いです。

Rでの実装を書いてくれています。時代の変遷や根拠となる論文を引きながら説明してくれている良い本です。細かいモデルごとの実装も説明してくれています。僕は間違えて2冊買ってしまいました。

英語が得意な方はこちらでしょうか。体系的に書いてくれています。なかなか他の章まで手が回っていません。

【統計的因果推論】周期性・季節性のある分割時系列解析をPythonで実装する【ITS・DeterministicProcess・Fourier級数】

1. 前回までのまとめ

少しシリーズが長くなってきたので、過去記事をどのようなモチベーションで書いてきたかを整理します。
syleir.hatenablog.com
分割時系列解析とはなにか?どのようなモチベーションでこの分析がやりたいのか?モデリングを提示し、基本的な考え方や求めたいものについてまとめました。
syleir.hatenablog.com
今までITSはRやStataでばかり実装されてきており、Pythonでの実装は確認できなかったので、Statmodelsというライブラリを用いて、折れ線回帰、セグメント回帰をPythonで実装し、グラフの描画をしました。
syleir.hatenablog.com
介入前後での傾きを求めるモデルについて、式変形を交えながら解説し、モデリングと結果の解釈について解説しました。

2. 今回の目標

時系列データには少なからず周期性が反映されます。季節性や周期性をもつ時系列データに対し、今までのような直線での折れ線回帰を用いた場合、当てはまりが悪いことがあります。そのような場合にどのような処理をしてITSを実装していくのかをPythonを使いながら説明していきます。

4. 使用データ

データが今回長いので折りたたんでいます。

5. 周期回帰分析について

別名をFourier analysisとか、Harmonic analysisとか言います。今回の記事の肝です。
フーリエ級数展開を利用して周期関数を回帰分析しようというモチベーションです。
周期性とフーリエ級数フーリエ変換の相性が良いのは過去のこの辺の記事でも触れています。

フーリエ級数展開というのは
周期 Tの関数 h(t)フーリエ級数展開できるとき、(実務上大体の関数はできます。できるための条件は専門書を参照ください。)

これとかには比較的詳しく書いてあります。

h(t) =  \dfrac{a_{0}}{2} + \sum^{\infty}_{n=1}(a_{n}\cos \dfrac{2\pi nt}{T} + b_{n} \sin \dfrac{2 \pi nt}{T})
と無限級数展開することを言います。
周期回帰分析では、この無限級数展開を途中で打ち切り、残りを誤差項と考え、実際のデータから a_n, b_nを推定するものになります。
例えば、n=1で切るとすると、a_0, a_1, b_1を推測することができれば、1次のフーリエ級数で近似した予測関数が得られることになります。何次の項まで考えるかはハイパーパラメータで、分析の時にデータを見ながら設計することになります。この係数も通常の回帰分析と同じように解析的に計算することができるので、周期関数を回帰分析で推測できるようになっています。

6. 今回のモデリング

 Y = \beta_0 + \beta_1 T + \beta_2 X_t + \beta_3(T - T_i) X_t + h(T)
ただしh(T)はフーリエ級数展開できる周期関数:つまり上で定義した関数とします。
今までのモデリングに加えて、h(T)が追加されています。この周期関数でのモデリングを追加することで周期性に対してrobustなモデルになります。

7. Rでのharmonic関数

Rは比較的簡単にharmonic関数という関数を用いることで簡単にフーリエ級数を用意できます。
harmonic(x, nfreq, period, intercept)というような引数を取りますが、
x:データ間隔を表す引数、月なのか日なのか
nfreq: 1期あたりに含まれる周期の数(上の式でいうn
period: 1期あたりのデータ点(上の式でいうT
intercept: 切片の有無(True: 上の式で a_0 \neq 0、False:  a_0 = 0)
とすれば良いです。例えば年単位の周期性を考えたく、データが1月に1つの今回のデータ(12データで1年)において、2次までのフーリエ級数展開の式を用意したければ、harmonic(month,2, 12,False) とすれば良いです。

8. PythonにおけるRでのharmonic関数に相当する実装

Pythonでは関数一つでこれを解決する実装は(少なくとも自分の実力・調査範囲では)難しいです。より直接的に \cos \dfrac{2\pi nt}{T}, \sin \dfrac{2\pi nt}{T}のデータ列を用意していくしかありません。ただ、DeterministicProcessというライブラリを使うことで少し楽ができます。これはデータ列を生成するためのライブラリで、季節ごとのダミー変数とか、月毎のダミー変数とか、今回利用するフーリエ級数列を用意することができる便利なライブラリです。
これを2次までのフーリエ級数を求める方針で実装してみると下のようになります。

#ライブラリの読み込み
from statsmodels.tsa.deterministic import DeterministicProcess, Fourier

#周期12, 2次までのフーリエ級数を用意する
fourier = Fourier(period=12, order = 2)

#データをDeterministicProcessにより生成
dp = DeterministicProcess(
    index=data.index, constant=False, order=0, drop=True, additional_terms=[fourier])

H = dp.in_sample()

pythonでのFourier関数はFourier(period, order)という引数を取ります。
period: 1周期の長さ(T)に相当します。Rのharmonic()におけるperiodと全く同じです。
order: フーリエ成分の数(n) Rのnfreqと全く同じです。
xはありませんが、前処理で調節するか、DeterministicProcessにおいてindexを目的のdatetime型に設定することで実現可能です。おそらくコードを読んでいても難しいと思うので結果を載せます。Hを最初の方だけ出力するとこうなります。

試しにH['sin(1,12)']を描画してみると次のようになります。

plt.figure(figsize=(16,8))
plt.plot(H['sin(1,12)'])
plt.xlabel('time',fontsize=18)
plt.xticks(np.arange(0, 48, 12))
plt.ylabel('y', fontsize=18)

ちゃんと \sin \dfrac{2\pi t}{12}となっていることがわかります。
これらのデータ列の線形結合がharmonic関数に相当するので、これらを変数に追加して今までのモデリングに適応してみれば良いです。

9. 変数の抽出

## 研究開始時点から経過した時間T
t = np.arange(0, (len(data)))
data["t"] = t

## 処置前後を表す二値変数Xt
x = np.where(data['date']>="2022-01-01", 1, 0)
data["xt"]=x

### 処置タイミングTiを取得、介入からの経過時間Xt(T-Ti)
ti = 24
data['xt(t-ti)'] = data['xt']*(data['t']-ti)

#変数の抽出
X1 = data[['t','xt','xt(t-ti)']]
H = dp.in_sample()[["sin(1,12)", "cos(1,12)","sin(2,12)","cos(2,12)"]]
y = data[['y']]

#X1とHを統合
X = X1.join(H)

これでデータの準備ができました。現在Xはこのようになっています。

モデルの作成と学習

ここからは前回記事とほぼ同じです。適宜省略します。

# 回帰モデル作成
mod = sm.OLS(y, sm.add_constant(X))
# 訓練
result = mod.fit()
# 統計サマリを表示
print(result.summary())


このように分析結果を出力することができます。原著論文では2次まで採用していたので2次で実装していますが、結果を見てみると、sin(2, 12) , cos(2, 12) は有意な変数ではないので1次までのモデリングでよかったかもしれません。

10. 推測を行う

#Xの0列目に'cep'というXと同じ長さの[1, 1, 1, ...]という列を挿入
X.insert(0, 'cep', [1]*len(X))
#学習したモデルから推測を行う
y_predict = result.predict(X)

ここで、推測してみたデータを描画してみます。

#グラフを描画
plt.figure(figsize=(16,8))
plt.title('ITS_test')
plt.plot(y, 'o')
plt.plot(y_predict)
plt.xlabel('time',fontsize=18)
plt.ylabel('event', fontsize=18)


赤実線が予測した線、青点が実際のデータですが、いい感じに学習できていることがわかります。

11. 反事実データの作成、推測

#反事実の作成
cf_X = X.copy(deep=True) 
cf_X['xt'] = 0
cf_X['xt(t-ti)'] = 0

#反事実を予測する
X_cf = cf_X[['t','xt','xt(t-ti)','sin(1,12)','cos(1,12)','sin(2,12)','cos(2,12)']]
X_cf.insert(0, 'cep', [1]*len(X_cf))
y_cf_predict = result.predict(X_cf)

#事実データに対する信頼区間を返す
predict = result.get_prediction(X)
frame = predict.summary_frame(alpha=0.05)
y_predict_l = frame['mean_ci_lower']
y_predict_u = frame['mean_ci_upper']

#反事実データに対する信頼区間を返す
predict_cf = result.get_prediction(X_cf)
frame_cf = predict_cf.summary_frame(alpha=0.05)

y_predict_cf_l = frame_cf['mean_ci_lower']
y_predict_cf_u = frame_cf['mean_ci_upper']

12. 描画

###描画
plt.style.use('fivethirtyeight')

#itv(intervention time)介入したところが何番目のデータか
#tiは上で定義済み(介入した時間)
itv = 24

plt.figure(figsize=(16,8))
plt.title('ITS_test')
plt.plot(y_predict)
plt.plot(y_cf_predict[(itv-1):], 'r--')
plt.plot(y, 'o')
plt.fill_between(data["date"], y_predict_l, y_predict_u, where= data["date"] >= "2022-01-01", color='b', alpha=0.2)
plt.fill_between(data["date"], y_predict_cf_l, y_predict_cf_u, where= data["date"] >= "2022-01-01",  color='r', alpha=0.2)
plt.xlabel('date',fontsize=18)
plt.xticks(np.arange(0, 48, 12))
plt.ylabel('event', fontsize=18)


出力がこれです。今までのモデルと比較してみましょう。


今までの折れ線回帰、セグメント回帰での出力はこれですから、相当当てはまりが改善していることがわかります。

pre-postの傾きの変化のモデルも前回の式変形の両辺にharmonic項を足すだけなので前回と同様に変数を用意して作ることができます。やってみてください。

おわりに

Rでのharmonic関数に相当する実装をPythonで行い、折れ線回帰分析に周期回帰分析を組み合わせたモデリングを行うことで周期性・季節性を考慮したITSがPythonで実装できました。ある程度は実務に耐えられるレベルまで昇華できた気がしますがいかがでしょうか。

少し難しかったでしょうか。過去の記事も見ながらじっくり取り組んでみてください。過去にはあまりフーリエ解析の記事は書けていないので基礎があやふやな方はこの辺の本を読んでみてください。統計検定準1級レベルでもたまに出るのでこの辺は押さえておくと良いです。

フーリエ解析の初歩の初歩を教えてくれる良書です。漫画だからといってナメてたんですけど、初学者にはわかりやすかったのでおすすめです。

次にフーリエ解析について学ぶならこの本がおすすめです。どう実務に生かしていくかを含めてわかります。

全実装

#ライブラリのインポート
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import statsmodels.api as sm
from statsmodels.tsa.deterministic import DeterministicProcess, Fourier

#ファイルをDataFrameとして読み込む
data = pd.read_csv("dummy_data.csv")

#周期12, 2次までのフーリエ級数を用意する
fourier = Fourier(period=12, order = 2)

#データをDeterministicProcessにより生成
dp = DeterministicProcess(
    index=data.index, constant=False, order=0, drop=True, additional_terms=[fourier])

H = dp.in_sample()

## 研究開始時点から経過した時間T
t = np.arange(0, (len(data)))
data["t"] = t

## 処置前後を表す二値変数Xt
x = np.where(data['date']>="2022-01-01", 1, 0)
data["xt"]=x

### 処置タイミングTiを取得、介入からの経過時間Xt(T-Ti)
ti = 24
data['xt(t-ti)'] = data['xt']*(data['t']-ti)

#変数の抽出
X1 = data[['t','xt','xt(t-ti)']]
H = dp.in_sample()[["sin(1,12)", "cos(1,12)","sin(2,12)","cos(2,12)"]]
y = data[['y']]

#X1とHを統合
X = X1.join(H)

# 回帰モデル作成
mod = sm.OLS(y, sm.add_constant(X))
# 訓練
result = mod.fit()
# 統計サマリを表示
print(result.summary())

#Xの0列目に'cep'というXと同じ長さの[1, 1, 1, ...]という列を挿入
X.insert(0, 'cep', [1]*len(X))
#学習したモデルから推測を行う
y_predict = result.predict(X)

#反事実の作成
cf_X = X.copy(deep=True) 
cf_X['xt'] = 0
cf_X['xt(t-ti)'] = 0

#反事実を予測する
X_cf = cf_X[['t','xt','xt(t-ti)','sin(1,12)','cos(1,12)','sin(2,12)','cos(2,12)']]
X_cf.insert(0, 'cep', [1]*len(X_cf))
y_cf_predict = result.predict(X_cf)

#事実データに対する信頼区間を返す
predict = result.get_prediction(X)
frame = predict.summary_frame(alpha=0.05)
y_predict_l = frame['mean_ci_lower']
y_predict_u = frame['mean_ci_upper']

#反事実データに対する信頼区間を返す
predict_cf = result.get_prediction(X_cf)
frame_cf = predict_cf.summary_frame(alpha=0.05)

y_predict_cf_l = frame_cf['mean_ci_lower']
y_predict_cf_u = frame_cf['mean_ci_upper']

###描画
plt.style.use('fivethirtyeight')

#itv(intervention time)介入したところが何番目のデータか
#tiは上で定義済み(介入した時間)
itv = 24

plt.figure(figsize=(16,8))
plt.title('ITS_test')
plt.plot(y_predict)
plt.plot(y_cf_predict[(itv-1):], 'r--')
plt.plot(y, 'o')
plt.fill_between(data["date"], y_predict_l, y_predict_u, where= data["date"] >= "2022-01-01", color='b', alpha=0.2)
plt.fill_between(data["date"], y_predict_cf_l, y_predict_cf_u, where= data["date"] >= "2022-01-01",  color='r', alpha=0.2)
plt.xlabel('date',fontsize=18)
plt.xticks(np.arange(0, 48, 12))
plt.ylabel('event', fontsize=18)

【統計的因果推論】分割時系列解析をPythonで結果の解析・解釈をする【ITS】

1. はじめに

前回の記事ではITSをPythonで実装しながら、視覚的にわかりやすいグラフを作るところまでを目的に実装してみました。今回の記事では視覚情報だけでなく、解析的にもわかりやすいデータを出力するコードを提供し、実務に耐えるような表を実装することを目標にしています。

記事① 分割時系列解析の基本
syleir.hatenablog.com
記事② Pythonでの分割時系列解析の実装
syleir.hatenablog.com
過去記事2作です。適宜こちらの記事に立ち戻ります。前回記事を読んでからのご参加を推奨させていただきます。

2. 求めたいものを確認する

記事①より、変数の定義と分割時系列解析によって知りたいものを確認します。

 
 \beta_0:切片 intercept
 \beta_1 : 介入前の傾き pre slope
 \beta_1 + \beta_3 :介入後の傾き post slope
 \beta_2 :介入前後の切片の差 level change
 \beta_3 :介入前後の傾きの差 slope change

でした。これが求めたいものになります。論文などでITSを使う際にはこれらのパラメータの点推定値、区間推定値、(p値)などが欲しいです。
前回記事②で実装した、サマリーによって出力が得られました。

これを見てみると、 \beta_0 = 1948, \beta_1 = 84.41, \beta_2 = -1483, \beta_3 = -128.1ということを示唆すると捉えることができたのでした。
上述の5変数のうち、4つはこのモデルで求まりますが、 \beta_1 + \beta_3はこれだけでは求まりません。
正確には \beta_1 + \beta_3 = 84.41 + (-128.1) = -43.6と計算することで求まりますが、これらの変数は点推定値だけではなく、95%信頼区間も欲しいです。どれくらいの精度で推定できているのかも興味がある事項になります。
信頼区間については、サマリーでこの部分を確認すれば良いことになります。

これにより、 \beta_0 , \beta_1 , \beta_2 , \beta_3については点推定、区間推定ができているので、あとは \beta_1 + \beta_3区間推定できれば良いことになります。

3.  \beta_1 + \beta_3の推定の準備をする

これについては少しコツが必要です。
まずは、記事①で定義した式、

 Y = \beta_0 + \beta_1 T + \beta_2 X_t + \beta_3(T - T_i) X_t を変形します。
 = \beta_0 + \beta_1 T + \beta_2 X_t + \beta_3 T X_t - \beta_3 T_i X_t (展開する)
 = \beta_0 + \beta_1 T + \beta_1 T X_t -  \beta_1 T X_t + \beta_2 X_t + \beta_3 T X_t - \beta_3 T_i X_t
( + \beta_1 T X_t -  \beta_1 T X_t を追加し、 \beta_3の形に合わせる)
 = \beta_0 + \beta_1 T -  \beta_1 T X_t + (\beta_1 + \beta_3) T X_t + \beta_2 X_t - \beta_3 T_i X_t
( + \beta_1 + \beta_3の形を作る←この式変形の目的)
 = \beta_0 + \beta_1 T(1 -  X_t) + (\beta_1 + \beta_3) T X_t + \beta_2 X_t - \beta_3 T_i X_t
 = \beta_0 + \beta_1 T(1 -  X_t) + (\beta_1 + \beta_3) T X_t + (\beta_2  - \beta_3 T_i)X_t
(変数ごとにまとめる)
とします。
式変形しただけですが、介入後の傾き \beta_1 +  \beta_3が求まるモデルに様変わりしていることがわかります。
変数として採用するのが、 T(1 -  X_t),  T X_t, X_tです。元々も3変数だったので自由度も変わらず、式変形しただけなのでこのモデルで予測される折れ線回帰も前回のモデルで実装したものと同じ結果が得られます。

4. 実装していく

前回の全実装部分を実行した後にデータの前処理を追加し T(1 -  X_t),  T X_t, X_tの項を用意していきます。

data["t(1-xt)"] = data["t"] *(1 - data["xt"])
data["t*xt"] = data["t"] * data["xt"] 

これで変数を追加しモデルを再構築します。

X = data[["t(1-xt)",'t*xt','xt']]
y = data[['y']]

# 回帰モデル作成
mod2 = sm.OLS(y, sm.add_constant(X))
# 訓練
result2 = mod2.fit() 
# 統計サマリを表示
print(result2.summary())

出力された結果が以下です。

式変形前のモデルと式変形後のモデルの学習結果を比較してみます。
(当然ですが、)偏回帰係数のパラメータ以外は全く同じものができています。

これらのパラメータを表にいい感じにまとめれば論文にも耐える形での実装が可能です。

5. おわりに

簡単ではありますが、PythonでのITSの実装例を提供しました。

この本で触れられている初歩的なものをさらに解説し、Pythonistaに向けた実装ができたと思います。この本では実際の論文を用いながら解説してくれているので併用しながら読んでみるとさらなる理解が深まると思います。

次は周期性のあるデータをITSで分析する記事を出そうかと考えています(いつになるかはわかりません)
時系列記事のPart4あたりが予習に良いかと思います。ここまではただの折れ線回帰をお話していましたが、この辺から時系列ぽさが出てきます。
syleir.hatenablog.com


6. 全実装

前回実装と今回実装をまとめたものを出しておきます。

#ライブラリのインポート
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import statsmodels.api as sm

#ファイルをDataFrameとして読み込む
data = pd.read_csv("dummy_data.csv")

## 研究開始時点から経過した時間T
t = np.arange(0, (len(data)))
data["t"] = t

## 処置前後を表す二値変数Xt
x = np.where(data['date']>="2021-10-01", 1, 0)
data["xt"]=x

### 処置タイミングTiを取得、介入からの経過時間Xt(T-Ti)
ti = data[data['date'] == "2021-10-01"].index
data['xt(t-ti)'] = data['xt']*(data['t']-ti)

#変数の抽出
X = data[['t','xt','xt(t-ti)']]
y = data[['y']]

# 回帰モデル作成
mod = sm.OLS(y, sm.add_constant(X))
# 訓練
result = mod.fit() 
# 統計サマリを表示
print(result.summary())

#Xの0列目に'cep'というXと同じ長さの[1, 1, 1, ...]という列を挿入
X.insert(0, 'cep', [1]*len(X))
#学習したモデルから推測を行う
y_predict = result.predict(X)

#反事実の作成
cf_data = data.copy(deep=True) 
cf_data['xt'] = 0
cf_data['xt(t-ti)'] = 0

#反事実を予測する
X_cf = cf_data[['t','xt','xt(t-ti)']]
X_cf.insert(0, 'cep', [1]*len(X_cf))
y_cf_predict = result.predict(X_cf)

#事実データに対する信頼区間を返す
predict = result.get_prediction(X)
frame = predict.summary_frame(alpha=0.05)
y_predict_l = frame['mean_ci_lower']
y_predict_u = frame['mean_ci_upper']

#反事実データに対する信頼区間を返す
predict_cf = result.get_prediction(X_cf)
frame_cf = predict_cf.summary_frame(alpha=0.05)

y_predict_cf_l = frame_cf['mean_ci_lower']
y_predict_cf_u = frame_cf['mean_ci_upper']

###描画
plt.style.use('fivethirtyeight')

#itv(intervention time)介入したところが何番目のデータか
#tiは上で定義済み(介入した時間)
itv = ti[0]
#tot(total time)総観測期間
tot = len(y)

plt.figure(figsize=(16,8))
plt.title('ITS_test')
plt.plot(y_predict)
plt.plot(y_cf_predict[(itv-1):], 'r--')
plt.plot(y, 'o')
plt.fill_between(np.arange(itv,tot),y_predict_cf_l[itv:], y_predict_cf_u[itv:], color='r', alpha=0.2)
plt.fill_between(np.arange(itv,tot),y_predict_l[itv:], y_predict_u[itv:], color='b', alpha=0.2)
plt.xlabel('time',fontsize=18)
plt.ylabel('event', fontsize=18)

###slope changeを求めるモデルを作る
#データの前処理
data["t(1-xt)"] = data["t"] *(1 - data["xt"])
data["t*xt"] = data["t"] * data["xt"] 

#変数を準備する
X = data[["t(1-xt)",'t*xt','xt']]
y = data[['y']]

# 回帰モデル作成
mod2 = sm.OLS(y, sm.add_constant(X))
# 訓練
result2 = mod2.fit() 
# 統計サマリを表示
print(result2.summary())

【統計的因果推論】分割時系列解析をPythonで実装する【ITS 回帰不連続デザイン】

はじめに

さて、前々回記事で分割時系列解析、分割時系列デザイン(Interrupted Time Series Analysis : ITS, ITSA)について解説したばかり(5ヶ月前)ですね。当該記事で2週間後には書くと言った気もしますが。。。
syleir.hatenablog.com
今日はこれをPythonで実装してみたいと思います。


この図を書くのが今日の目標です。
調べてみたところ該当する記事は見当たらなかったので参考にしてください。独力で実装したので改善点、誤りなどあれば教えてください。全実装はページの一番下にあります。目次からもジャンプできます。

0.使用データ

date y
2020-10-01 2030
2020-11-01 2201
2020-12-01 2100
2021-01-01 2000
2021-02-01 2035
2021-03-01 2343
2021-04-01 2854
2021-05-01 2400
2021-06-01 2748
2021-07-01 2443
2021-08-01 2600
2021-09-01 3194
2021-10-01 1506
2021-11-01 1340
2021-12-01 1456
2022-01-01 1256
2022-02-01 1345
2022-03-01 1100
2022-04-01 1304
2022-05-01 1405
2022-06-01 1245
2022-07-01 852
2022-08-01 1200
2022-09-01 840

今回はこのデータを.csvで持ちます。これをpythonで読み込んでデータ処理していきます。2022年9月に公開しようと思ってたのでデータは2022年9月までです。

1.データの読み込み

import pandas as pd
#ファイルをDataFrameとして読み込む
data = pd.read_csv("dummy_data.csv")

2.データの前処理

読み込んだデータを処理していきます。まずは今回作るモデルの
 Y = \beta_0 + \beta_1 T + \beta_2 X_t + \beta_3(T - T_i) X_tを処理していきます。この辺の話は前回記事を参考にしてください。
今回のデータでは2021-10-01に処置開始のイベントが生じたとします。

T は実験開始からの時間
X_tは処置前後を表す二値変数(0なら処置前、1なら処置後)
T_iは処置が行われた時間T を表します。

import numpy as np

## 研究開始時点から経過した時間T
t = np.arange(0, (len(data)))
data["t"] = t

## 処置前後を表す二値変数Xt
x = np.where(data['date']>="2021-10-01", 1, 0)
data["xt"]=x

### 処置タイミングTiを取得、介入からの経過時間Xt(T-Ti)
ti = data[data['date'] == "2021-10-01"].index
data['xt(t-ti)'] = data['xt']*(data['t']-ti)

ここでdataを出力してみるとこのようになります。

ほしい変数がdataframeに追加されていることがわかります。

3.変数の抽出

説明変数Xから目的変数yを学習するモデルを作ります。余談ですが、慣習的に機械学習分野では(行列は大文字、ベクトルは小文字で表すことに倣って)説明変数は大文字、目的変数は小文字で表します。

#変数の抽出
X = data[['t','xt','xt(t-ti)']]
y = data[['y']]

このように抽出するとXとyは下のようになります。

4.モデルの作成と学習

pythonにおいては回帰分析はscikit-learnなどでもできますが、今回のような詳細なパラメータがほしい場合はstatsmodelsというライブラリを使用します。pythonにおける回帰分析はめちゃくちゃ簡単に実装でき、3行で結果が表示できます。良い時代ですね。add_constantという関数はy = axではなく y = ax + bのように定数項も含めて評価してねという関数になります。今回はこれが必要です。

import statsmodels.api as sm

# 回帰モデル作成
mod = sm.OLS(y, sm.add_constant(X))
# 訓練
result = mod.fit() 
# 統計サマリを表示
print(result.summary())

最終行はGoogle ColaboratoryやJupyter notebookのような環境を使っている方であれば単純に

result.summary()


でも良いです。

自分はこちらの方が体裁が整っていて好きです。

5.学習結果を見る

今回の回帰分析で見るのはココです。

ちなみに、この部分を取り出すときはこうです。

coef = np.array(result.params)

としてcoefを出力すると、[ 1948.06410256, 84.41258741, -1483.502331 , -128.06643357]と出てきます。
これは学習結果が、
 Y = \beta_0 + \beta_1 T + \beta_2 X_t + \beta_3(T - T_i) X_t
において \beta_0 = 1948, \beta_1 = 84.41, \beta_2 = -1483, \beta_3 = -128.1ということを示しています。

6.推測を行う

実際に、この学習した式に対してデータを入れてみたときどのような挙動をするのかを検証してみたいと思います。
これは1行で実装ができて、

#学習したモデルから推測を行う
y_predict = result.fittedvalues

これで終わりです。y_predictを出力してみると

このように出てきます。

ただ、今回はこの式に介入をしていなかったときを想定した反事実の予測もしてみたいので、別実装を用意します。

別実装

#Xの0列目に'cep'というXと同じ長さの[1, 1, 1, ...]という列を挿入
X.insert(0, 'cep', [1]*len(X))

こうすると

このようにXの0列目に大量の1が挿入されます。この操作は行列のサイズを合わせるために必要で、定数項=1*定数項とみなしています。
便宜上やるお作法です。

この行列Xを利用して推測をする関数が以下で、実質的には上と変わらない挙動をしていますが、Xを他の行列に置換することで違うXに対しても予測ができます。

#学習したモデルから推測を行う
y_predict = result.predict(X)

この時のy_predictは以下で、上の実装での結果と一致していることがわかります(当然ですが。)

ちょっとここまでの結果を描画してみます。

plt.style.use('fivethirtyeight')

plt.figure(figsize=(16,8))
plt.title('ITS_test')
plt.xlabel('time',fontsize=18)
plt.ylabel('event', fontsize=18)
plt.plot(y, 'o')

plt.plot(y_predict)


まともに学習できていることがわかります。

7.反事実データを作成

さて、統計的因果推論のキモは事実と反事実の違いを描出することでした。
この場合の反事実というのは介入を行っていない、ということに相当します。

元の変数に立ち戻ってみると、
T は実験開始からの時間
X_tは処置前後を表す二値変数(0なら処置前、1なら処置後)
T_iは処置が行われた時間T を表します。

でした。
介入を行わない場合、
X_tはいついかなる時も0です。
またT_iは定義できません。(無限大という考え方もできます。)
ということで、(T - T_i) X_tも介入前は全て0として扱っていましたから、これも0になります。

これを実装すると、

#反事実の作成
cf_data = data.copy(deep=True) 
cf_data['xt'] = 0
cf_data['xt(t-ti)'] = 0

とすれば良いです。ちなみにcfというのは反事実的な(counter-fuctual)の略です。

8.反事実を予測する

先ほどと同じように使用する変数を取り出し、1を挿入してから予測してみます。

#反事実を予測する
X_cf = cf_data[['t','xt','xt(t-ti)']]
X_cf.insert(0, 'cep', [1]*len(X_cf))
y_cf_predict = result.predict(X_cf)


9.反事実を描画する

plt.style.use('fivethirtyeight')

plt.figure(figsize=(16,8))
plt.title('ITS_test')
plt.xlabel('time',fontsize=18)
plt.ylabel('event', fontsize=18)
plt.plot(y, 'o')

plt.plot(y_cf_predict)


介入がなかった場合に時系列データがこのような推移をするという結果がよくわかると思います。
 Y = \beta_0 + \beta_1 T + \beta_2 X_t + \beta_3(T - T_i) X_tで後ろ2項が常に0の場合に相当し、
 Y = \beta_0 + \beta_1 Tとなります。  \beta_1 というのは前回記事で書いたように処置前の傾きの推定量ですから、
このような直線形になるわけです。

10.信頼区間を表示する

www.statsmodels.org

さて、今回の記事の肝はこれです。
Pythonの問題点は折れ線回帰(セグメント回帰)における信頼区間と予測区間の描出が難しいことにあります。

信頼区間と予測区間

結構信頼区間と予測区間は混同されがちではありますが、
信頼区間:実際の直線がこの範囲に入っている確率が1-α以上
予測区間:サンプルデータがこの範囲に入っている確率が1-α以上
というように信頼区間と予測区間は意味が違います。基本的には信頼区間の方が予測区間より狭いです。
今回欲しいのは信頼区間になりますが、予測区間についても一応実装を残しておきます。

さて、一般的に「回帰分析 信頼区間」などでググると出てくるのはwls_prediction_stdを用いた以下のようなコードではないでしょうか。


from statsmodels.sandbox.regression.predstd import wls_prediction_std
prstd, lower, upper  = wls_prediction_std(result)

これで、lower, upperを出力してみます。

出てくるのは予測区間です。これを混同した記事がかなりあるので注意してください。そして今回欲しいのは信頼区間です。これは今回のwls_prediction_stdでは出てきません。predictionなので。
tedboy.github.io
この公式ドキュメントを暇な時に読んでいただければ良いですが、これ以上の機能はないようです。困りましたね。

一般的なセグメント回帰、折れ線回帰に対して予測区間を表示するための実装としてはこのような実装がありますが、(ちなみに信頼区間もできます)

#信頼区間を抽出
from statsmodels.stats.outliers_influence import summary_table
st, data, ss2 = summary_table(result, alpha=0.05)

y_predict_l, y_predict_u = data[:, 4:6].T

plt.plot(y_predict_l, 'r--', lw=2)
plt.plot(y_predict_u, 'r--', lw=2)
#描画
plt.style.use('fivethirtyeight')

plt.figure(figsize=(16,8))
plt.title('ITS_test')
plt.plot(y_predict)
plt.plot(y_predict_cf[11:], 'r--')
plt.plot(y, 'o')


plt.xlabel('time',fontsize=18)
plt.ylabel('event', fontsize=18)

plt.plot(y_predict_l, 'g--', lw=2)
plt.plot(y_predict_u, 'g--', lw=2)

これではITSにおいて肝となる反事実のグラフにおける描画ができません。(とはいえ反事実はただの単回帰分析+αなので実装はそこまで難しくないですが)

今回使うのは
www.statsmodels.org
これのsummary_frameを使います。ドキュメント見ても難しいと思うので実装していきます。

#事実データに対する信頼区間を返す
predict = result.get_prediction(X)
frame = predict.summary_frame(alpha=0.05)
y_predict_l = frame['mean_ci_lower']
y_predict_u = frame['mean_ci_upper']

#反事実データに対する信頼区間を返す
predict_cf = result.get_prediction(X_cf)
frame_cf = predict_cf.summary_frame(alpha=0.05)

y_predict_cf_l = frame_cf['mean_ci_lower']
y_predict_cf_u = frame_cf['mean_ci_upper']

frame というのはこのような表で、信頼区間、予測区間の上下を示してくれている表になります。mean_ci_lower/upper が信頼区間で、obs_ci_lower/upper が予測区間です。ちなみにobsというのはobservationの略で、新しくデータが「観測」されるとするならこの区間にきますよ、という予測区間の意味を反映しています。


この実装の良いところはほぼ同一の実装で事実、反事実ともに信頼区間を返してくれ、重実装にならないところです。
あとは信頼区間の間をいい感じに塗れば完成します。

塗るときはfill_betweenを使いましょう。

plt.style.use('fivethirtyeight')

#itv(intervention time)介入したところが何番目のデータか
#tiは上で定義済み(介入した時間)
itv = ti[0]
#tot(total time)総観測期間
tot = len(y)

plt.figure(figsize=(16,8))
plt.title('ITS_test')
plt.plot(y_predict)
plt.plot(y_cf_predict[(itv-1):], 'r--')
plt.plot(y, 'o')
plt.fill_between(np.arange(itv,tot),y_predict_cf_l[itv:], y_predict_cf_u[itv:], color='r', alpha=0.2)
plt.fill_between(np.arange(itv,tot),y_predict_l[itv:], y_predict_u[itv:], color='b', alpha=0.2)
plt.xlabel('time',fontsize=18)
plt.ylabel('event', fontsize=18)

これでいい感じのグラフができました。

おわりに

一般的にこういう統計的手法はRやSASなどを使いますが、Pythonでも可能です。
matplotlibやseabornがある分描画はかなりやりやすく、ライブラリの使い方が難しい程度で自由度はかなり高いです。
なんらかのものに引用する場合は一声かけるか引用を明示してくれるとありがたいです。なんらかの間違いはあると思うので。。

10000字書きました。疲れました。またいつか気が向いたらなにかを書きます。

→続編です。slope change( \beta_1 + \beta_3)を求めて結果の解析を行う記事です。
syleir.hatenablog.com



おすすめの書籍

おそらく現状で唯一(他にあったらぜひ教えてください)分割時系列解析に触れた成書です。ぜひ。医学雑誌ですけど統計回で普通に統計の勉強になります。

syleir.hatenablog.com
上にも貼ってますが分割時系列解析の解説記事です。

syleir.hatenablog.com
統計的因果推論の初歩の初歩の解説記事です。

syleir.hatenablog.com
統計検定準1級レベルの時系列解析をまとめています。


余談

最近流行りのChatGPTに聞いてみたら大嘘吐かれました。

全実装

説明のための余計なコードを省略したものです。上からStep by stepで実行していただくとわかりやすいかもです。

#ライブラリのインポート
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import statsmodels.api as sm

#ファイルをDataFrameとして読み込む
data = pd.read_csv("dummy_data.csv")

## 研究開始時点から経過した時間T
t = np.arange(0, (len(data)))
data["t"] = t

## 処置前後を表す二値変数Xt
x = np.where(data['date']>="2021-10-01", 1, 0)
data["xt"]=x

### 処置タイミングTiを取得、介入からの経過時間Xt(T-Ti)
ti = data[data['date'] == "2021-10-01"].index
data['xt(t-ti)'] = data['xt']*(data['t']-ti)

#変数の抽出
X = data[['t','xt','xt(t-ti)']]
y = data[['y']]

# 回帰モデル作成
mod = sm.OLS(y, sm.add_constant(X))
# 訓練
result = mod.fit() 
# 統計サマリを表示
print(result.summary())

#Xの0列目に'cep'というXと同じ長さの[1, 1, 1, ...]という列を挿入
X.insert(0, 'cep', [1]*len(X))
#学習したモデルから推測を行う
y_predict = result.predict(X)

#反事実の作成
cf_data = data.copy(deep=True) 
cf_data['xt'] = 0
cf_data['xt(t-ti)'] = 0

#反事実を予測する
X_cf = cf_data[['t','xt','xt(t-ti)']]
X_cf.insert(0, 'cep', [1]*len(X_cf))
y_cf_predict = result.predict(X_cf)

#事実データに対する信頼区間を返す
predict = result.get_prediction(X)
frame = predict.summary_frame(alpha=0.05)
y_predict_l = frame['mean_ci_lower']
y_predict_u = frame['mean_ci_upper']

#反事実データに対する信頼区間を返す
predict_cf = result.get_prediction(X_cf)
frame_cf = predict_cf.summary_frame(alpha=0.05)

y_predict_cf_l = frame_cf['mean_ci_lower']
y_predict_cf_u = frame_cf['mean_ci_upper']

###描画
plt.style.use('fivethirtyeight')

#itv(intervention time)介入したところが何番目のデータか
#tiは上で定義済み(介入した時間)
itv = ti[0]
#tot(total time)総観測期間
tot = len(y)

plt.figure(figsize=(16,8))
plt.title('ITS_test')
plt.plot(y_predict)
plt.plot(y_cf_predict[(itv-1):], 'r--')
plt.plot(y, 'o')
plt.fill_between(np.arange(itv,tot),y_predict_cf_l[itv:], y_predict_cf_u[itv:], color='r', alpha=0.2)
plt.fill_between(np.arange(itv,tot),y_predict_l[itv:], y_predict_u[itv:], color='b', alpha=0.2)
plt.xlabel('time',fontsize=18)
plt.ylabel('event', fontsize=18)