傾向スコアマッチング(PSM)とは?
(画像はGeminiで作成)
傾向スコアマッチングの概要
ビジネスや医療の現場において、「ある施策(または治療)に効果があったのか?」を検証することは非常に重要です。 しかし、実際の観察データ(Observational Data)を用いて単純に「施策を受けたグループ」と「受けなかったグループ」の平均値を比較すると、 セレクションバイアス(選択バイアス) の影響を強く受けてしまいます。
セレクションバイアスとは、施策を受けるグループと受けないグループの間で、結果に影響を与える背景要因(交絡因子)が元々異なっていることによって生じる偏りのことです。 たとえば、「有料会員向けキャンペーンの効果」を測る際、そもそも有料会員になるユーザーは最初から購買意欲が高い(優良顧客である)といった背景の違いが挙げられます。
このバイアスを取り除き、あたかもランダム化比較試験(RCT)を行ったかのような状況を擬似的に作り出す強力な手法が、 傾向スコアマッチング(Propensity Score Matching: PSM) です。 傾向スコアマッチングは、観察データから比較効果研究を行うための最も確立され、広く使用されている戦略の1つです。
数値例で学ぶセレクションバイアス
先ほど挙げた「有料会員向けキャンペーン」の例を、具体的な数値で考えてみましょう。あなたはキャンペーンの成果を測るため、以下のデータを集めました。
- キャンペーン対象者(有料会員)の平均購入額:10,000円
- キャンペーン非対象者(無料会員)の平均購入額:5,000円
この結果を見て、「キャンペーンには +5,000円 の効果があった!」と報告して良いでしょうか? 答えは「ノー」です。なぜなら、ここには大きなセレクションバイアスが潜んでいるからです。
実は、キャンペーンを実施しなかった世界(反事実)での本来の実力は以下のようになっていたとします。
- 有料会員は、元々購買意欲が高く、キャンペーンがなくても平均 8,000円 購入する。
- 無料会員は、平均 5,000円 購入する。
つまり、有料会員と無料会員の間には、最初から「3,000円(8,000円 - 5,000円)」という元々の実力差(ベースラインの違い) が存在していました。 キャンペーンによる「真の効果」は、有料会員が本来買うはずだった8,000円から10,000円に増えた分の +2,000円 だけです。
単純に「対象者(10,000円) − 非対象者(5,000円)」を比較してしまうと、真の効果(2,000円)に元々の実力差(3,000円)が合算されてしまい、効果を「5,000円」だと過大評価してしまいます。 このように、施策の有無とは別の要因(今回で言えば「会員ランク」や「元々の購買意欲」などの交絡因子)によって生じる見せかけの差が、セレクションバイアスです。
傾向スコアとは?
傾向スコア(Propensity Score)とは、1983年にRosenbaumとRubinによって提案された概念であり、 観測された共変量のベクトルが与えられた際に、特定の治療(または施策)に割り当てられる条件付き確率のことです。 数式で表すと、共変量(特徴量)ベクトルを 、施策の有無を (なら施策あり、なら施策なし)としたとき、傾向スコア は以下のように定義されます。
ここで出てくる という数式は少し難しく見えますが、機械学習を学んだ方であれば、ロジスティック回帰などの出力で使われるシグモイド関数 の目的変数 を、施策の有無 に置き換えただけの 「確率 」 のことだと考えれば非常にシンプルです。
つまり、多次元にわたる複雑なユーザーの特徴(年齢、性別、購買履歴など)を入力 としたとき、モデルが弾き出す「施策対象になる確率(0から1までの確率値 )」という1つの値に要約したものが傾向スコアなのです。
因果推論の文脈で頻繁に登場する 「共変量(Covariates)」 とは、機械学習や統計学で言うところの 「特徴量(Features)」 や 「説明変数(Explanatory Variables)」 と全く同じものを指しています。
なお、本記事では「共変量」と並んで 「交絡因子(Confounder)」 という語も登場しますが、この2つは同義ではありません。共変量が「観測されている説明変数」全般を指すのに対し、交絡因子は「施策の有無 と結果 の両方に影響を与える変数」という因果的な役割を指し、共変量の一部にあたります。たとえば「売上(結果)には影響するが、DM送付の対象になりやすさには関係しない変数」があったとすれば、それは共変量ではありますが交絡因子ではありません。セレクションバイアスの原因となるのは、両方に影響する交絡因子だけです。
傾向スコアを算出する際、一般的には 「施策の有無(処置変数 )」 を目的変数とし、「ユーザーの属性や行動履歴(共変量 )」 を説明変数としたロジスティック回帰モデルを構築します。
後述するダミーデータの例では、ユーザーの「年齢(Age)」と「過去の購買金額(History)」という2つの変数が、DM送付(施策)されやすさと売上(結果)の両方に影響するため、共変量であると同時に交絡因子でもあります。本記事のダミーデータで観測される変数はこの2つだけなので、結果的に「共変量=交絡因子」となります。以降の解説では文脈に応じて両方の語が登場しますが、いずれも同じ Age と History の2変数を指していると考えて差し支えありません。
この2つの変数から、DM送付(処置変数 )の対象になる確率 を、以下のようなロジスティック回帰(シグモイド関数)の式で定式化しています。
(※式中の は、ロジスティック回帰モデルの学習によってデータから自動的に推定される「偏回帰係数(各変数の重み)」を表しています。)
このように、傾向スコアの算出は「機械学習の分類タスク(施策を受けるか受けないかの2値分類)」を行っていることと同義です。一方、傾向スコアが調整できるのは「観測できた共変量」だけであり、データに存在しない未観測の交絡因子は調整できません。この点は後述の前提条件で改めて触れます。
ロジスティック回帰がどのような確率を計算しているのか、少し直感的な例で考えてみましょう。
もし、年齢や購買金額といった 「全く同じ特徴量 」 を持ったユーザーが10人いたとします。現実のデータにおいて、その10人のうち 6人 がキャンペーン対象()になり、4人 が対象外()だったとします。
このとき、ロジスティック回帰モデルにこの特徴量 を入力すると、モデルはシンプルに過去の割合を学習し、「この特徴量を持つ人が施策を受ける確率 は 0.6 だ」 と出力するようになります(当然、 は 0.4 になります)。
傾向スコアとは、まさにこの 「同じ特徴量を持った人が複数いたとき、過去の実績から見てどのくらいの割合で施策を受けていたか」 という期待値を、複雑な多次元データから滑らかな数式で予測したものに他なりません。
傾向スコアマッチングの基本ロジック
(画像はGeminiで作成)
傾向スコアマッチングの仕組みは非常に直感的です。 主なステップは以下の2つに分かれます。
- スコアの算出:
各ユーザーが「どれくらい施策を受けそうだったか」を予測するモデルを構築します。 実務では、目的変数を「施策の有無(0 or 1)」とし、共変量(ユーザー属性や過去の行動履歴)を説明変数としたロジスティック回帰がよく用いられます。 - 最近傍マッチング(Nearest Neighbor Matching):
算出された傾向スコアを用いて、施策を受けたユーザーと、施策を受けていないユーザーの中で、 スコアが最も近い(似ている)者同士をペアにしていきます。 傾向スコア は、モデルに投入した共変量 の分布を治療群間でバランスさせるという特性(Balancing score)を持っています。 この特性を利用して正確な傾向スコアで対象者をマッチングすることで、ベースライン変数の分布を治療群間で期待的に均衡させることができます。
ここで重要なのは、同じ傾向スコアでマッチングされた2人の対象者は、個別の共変量の値が異なる可能性があるということです。 しかし、この不一致は偶然によるものであり、治療の割り当てを予測するものではありません。 マッチングされたペアの数が増えれば、グループ間の不均衡は無視できるほど小さくなります。
検証用ダミーデータの生成
それでは、Pythonを使って実際の処理を確認していきましょう。まずは、交絡因子を含んだダミーデータを生成します。 シナリオとして、「購買履歴」と「年齢」が高いユーザーほど「DM送付(施策)」の対象になりやすく、かつ売上(ターゲット)も高くなりやすい状況を作ります。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import japanize_matplotlib
np.random.seed(42)
# データサイズの指定
N = 2000
# 1. 交絡因子(特徴量)の生成
# age: 年齢 (20〜60), history: 過去の購買金額
age = np.random.normal(40, 10, N)
history = np.random.exponential(5000, N)
# 2. 施策割り当て(セレクションバイアスの導入)
# 年齢が高く、過去の購買金額が多いほどDMが送られやすい(ロジスティック関数を使用)
logit_p = -3.0 + 0.05 * age + 0.0002 * history
propensity = 1 / (1 + np.exp(-logit_p))
# 確率に基づいて施策群(1)と対照群(0)を割り当て
treatment = np.random.binomial(1, propensity)
# 3. 目的変数(売上)の生成
# 真の施策効果(True Effect)を +1500 とする
true_effect = 1500
# 売上 = ベース + 年齢要因 + 履歴要因 + 施策効果 + ノイズ
sales = 2000 + 30 * age + 0.5 * history + true_effect * treatment + np.random.normal(0, 1000, N)
# データフレームの作成
df = pd.DataFrame({
'age': age,
'history': history,
'treatment': treatment,
'sales': sales
})
# 施策群と対照群の単純な平均の比較
print("単純比較での売上の差:", df[df['treatment'] == 1]['sales'].mean() - df[df['treatment'] == 0]['sales'].mean())
- 交絡因子の生成: np.random.normal(40, 10, N) と np.random.exponential(5000, N) を用いて、交絡因子となる「年齢」と「過去の購買金額」の分布を生成しています。
- 傾向スコア真値の算出: logit_p にて、年齢と購買金額が高いほど値が大きくなる線形和を計算し、これを 1 / (1 + np.exp(-logit_p)) のシグモイド関数に通すことで、各ユーザーが施策対象となる確率を算出しています。
- セレクションバイアスの導入: np.random.binomial(1, propensity) によって、計算された確率に基づいたコイントス(ベルヌーイ試行)を行い、施策の有無(処置変数 )をランダムに割り当てています。これにより、「購買金額が多い人ほど施策を受けやすい」というセレクションバイアスを人工的に導入しています。
- 目的変数の構築: 最後に、ベースの売上(2000)に年齢と購買金額の係数を掛け合わせたものを足し、さらに真の施策効果である true_effect * treatment(1500)とランダムノイズを加えることで、最終的な目的変数 sales を構築しています。
このダミーデータは、以下の数式(データ生成プロセス)に基づいています。
① 目的変数(売上)の生成
私たちが最終的に知りたいのは、この式にある (施策による真の効果)です。しかし、売上には「年齢」や「購買金額」といった要素(交絡因子)も同時に影響を与えています。
② 交絡因子の生成
(※ 数学的な慣例では指数分布を と率パラメータ で表記しますが、本記事ではPython(numpy)の実装パラメータと対応させるため、平均値を示す scale=5000( に相当)として明記しています。)
③ 施策割り当て(セレクションバイアスの導入)
実行結果
単純比較での売上の差: 3303.112367632807
上記のコードを実行すると、真の施策効果は「1500」であるにもかかわらず、数式①の通り売上には「年齢(Age)」と「購買履歴(History)」による底上げが合算されているため、単純比較ではセレクションバイアスの影響でそれよりも大きな(過大評価された)差が算出されてしまうことがわかります。
スクラッチでの実装(ロジスティック回帰と自力でのマッチング)
次に、ライブラリの裏側で行われている「スコア算出」と「距離計算によるペアリング」をスクラッチで実装してみます。
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import NearestNeighbors
# 1. ロジスティック回帰で傾向スコアを算出
X = df[['age', 'history']]
y = df['treatment']
# モデルの学習
model = LogisticRegression()
model.fit(X, y)
# 傾向スコアの予測(施策を受ける確率)
df['propensity_score'] = model.predict_proba(X)[:, 1]
# 2. 最近傍マッチング(Nearest Neighbor Matching)の実装
treated = df[df['treatment'] == 1].copy()
control = df[df['treatment'] == 0].copy()
# 対照群のスコアを用いてKNNモデルを構築
nn = NearestNeighbors(n_neighbors=1, metric='euclidean')
nn.fit(control[['propensity_score']])
# 施策群の各データに対して、最もスコアが近い対照群のインデックスを取得
distances, indices = nn.kneighbors(treated[['propensity_score']])
# キャリパー(許容できる最大の距離)の設定
caliper = 0.05
matched_indices = []
treated_indices = []
for i, (dist, idx) in enumerate(zip(distances, indices)):
if dist[0] <= caliper:
matched_indices.append(idx[0])
treated_indices.append(i)
# マッチングされたデータフレームの作成
matched_control = control.iloc[matched_indices].copy()
matched_treated = treated.iloc[treated_indices].copy()
df_matched = pd.concat([matched_treated, matched_control])
# マッチング後の効果測定
estimated_effect = matched_treated['sales'].mean() - matched_control['sales'].mean()
print("マッチング後の推定効果:", estimated_effect)
import seaborn as sns
# 3. マッチング前後の傾向スコア分布の可視化
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# マッチング前の分布(セレクションバイアスあり)
sns.histplot(data=df, x='propensity_score', hue='treatment',
kde=True, bins=30, ax=axes[0], palette=['royalblue', 'darkorange'], alpha=0.6)
axes[0].set_title('マッチング前の傾向スコア分布 (バイアスあり)')
# マッチング後の分布(共変量の均衡化)
sns.histplot(data=df_matched, x='propensity_score', hue='treatment',
kde=True, bins=30, ax=axes[1], palette=['royalblue', 'darkorange'], alpha=0.6)
axes[1].set_title('マッチング後の傾向スコア分布 (共変量の均衡化)')
plt.tight_layout()
plt.show()
- ロジスティック回帰によるスコア算出: LogisticRegression を用いて、交絡因子(Age, History)から処置変数(treatment)を予測し、各データの傾向スコア propensity_score を算出します。
- 最近傍探索でのペア探し: NearestNeighbors(最近傍探索モデル)を対照群のスコアで学習(fit)させ、施策群の各スコアに対して最も距離が近い対照群のインデックスを kneighbors で取得します。
- キャリパーによる足切りとデータフレーム再構築: スコアの差が caliper = 0.05 以下(非常に似ているペア)のみを matched_indices に抽出し、ペアになれなかったデータは除外(破棄)することで、新たなデータフレーム df_matched を作成します。なお、本スクリプトでは簡易的に傾向スコアそのものの差分(0.05)を設定していますが、実務上の慣例としては「傾向スコアのロジットの標準偏差 × 0.2」をキャリパー幅として設定することが推奨されています(Austin, 2011)。
- 効果の再計算: 条件が揃った df_matched 内で売上の平均差を再計算すると、過大評価されていた差が相殺され、真の施策効果(1500)に非常に近い値が推定されます。
- マッチング前後の分布可視化: seaborn.histplot を用いてマッチング前後のスコア分布を可視化しています。マッチング前のグラフではオレンジ色(施策あり群)が右側に、青色(施策なし群)が左側に偏っていますが、マッチング後のグラフでは見事に分布がピタリと重なり合い、観測された共変量についてはセレクションバイアスが十分に緩和され、均衡したことが視覚的にも確認できます。
実行結果
マッチング後の推定効果: 1514.0925990049554

このアプローチにより、交絡因子の影響が相殺され、真の効果である「1500」に近い値が推定されます。
上記の NearestNeighbors を用いた実装では、1つの「対照群(施策なし)のユーザー」が、複数の「施策群ユーザー」のペア相手として重複して選ばれる可能性があります。これを専門用語で 復元抽出(Matching with Replacement) と呼びます。
復元抽出は、よりスコアの近い最適なペアを見つけやすいというメリットがある一方で、一部のデータが何度も再利用されるため、推定された効果の「ばらつき(標準誤差)」が過小評価されてしまう(結果が実際よりも有意であると勘違いしやすくなる)という統計的な課題を孕んでいます。
実務で厳密な検定を行う場合は、非復元抽出(重複を許さないマッチング)アルゴリズムを用いるか、ライブラリを使って重複を考慮した標準誤差の補正を行うのが一般的です。
上記のスクラッチ実装のコードを見ると、treated(施策群)の各データに対して、最もスコアが近い control(対照群)を探索してペアを作っています。このように 「施策を受けた人たち」に限定して算出した効果 を、因果推論では ATT と呼びます。
実務や因果推論のライブラリ出力では、主に以下の3つの指標が使い分けられます。
① ATE(Average Treatment Effect:平均処置効果)
集団のすべての人が「もし全員施策を受けた場合」と「もし全員施策を受けなかった場合」の結果の差の平均。集団全体に対する一般的な効果を知りたい場合に用います。
(※ は施策ありの結果、 は施策なしの結果)
② ATT(Average Treatment effect on the Treated:処置群における平均処置効果)
「実際に施策を受けた人たち」 に限定して、その人たちが「もし施策を受けていなかった場合」と比べたときの結果の差の平均。マーケティングの実務において、「実際にキャンペーン対象となった人への投資対効果」を知りたい場合に最も重要視される指標です。
③ ATC(Average Treatment effect on the Control:対照群における平均処置効果)
「実際には施策を受けなかった人たち」 に限定して、その人たちが「もし施策を受けていた場合」にどう変化したかの差の平均。「施策を打たなかった層にもし施策を拡大したらどうなるか?」をシミュレーションしたい場合に用いられます。
今回のスクラッチ実装は施策群を基準にマッチングを行っているため、出力された推定効果は ATT に該当します。
マッチング後の共変量バランスの確認(SMD)
前節では、マッチング前後の傾向スコアの分布が重なる様子を可視化しました。しかし、傾向スコアはあくまで共変量を1次元に圧縮した「要約値」です。マッチングの目的は、スコアそのものを揃えることではなく、その背後にある共変量(年齢・購買履歴)の分布を2群で揃えることにあります。したがって、効果の推定値を報告する前に、共変量が実際に均衡したかを変数ごとに確認する工程が必要です。
今回は共変量が2つだけなので図を並べれば済みますが、実務では数十個に及ぶことも珍しくありません。そこで用いられるのが、RCTの記事で紹介した 標準化平均差(SMD: Standardized Mean Difference) です。
SMDは、処置群と対照群における共変量の平均の差を、2群の分散をプールした標準偏差で割った値として定義されます。
( は処置群・対照群における共変量の平均、 はそれぞれの分散)
無次元でサンプルサイズにも依存しないため、スケールの異なる共変量を横並びで比較でき、慣例的に をバランスの目安とします。
定義の詳細(なぜp値ではなくSMDを使うのか、0.1という基準の意味)は、RCTの記事「標準化平均差(SMD)による評価」で解説しています。
PSMの場合、この式に手を加える必要はありません。マッチング前の全データと、マッチング後に残ったデータセットのそれぞれに同じ式を当てはめ、値がどれだけ基準線の内側に移動したかを比較します。
# 標準化平均差(SMD):RCT記事の定義式をそのまま実装
def smd(x_t, x_c):
return (x_t.mean() - x_c.mean()) / np.sqrt((x_t.var(ddof=1) + x_c.var(ddof=1)) / 2)
# マッチング前(df 全体)と マッチング後(df_matched)で同じ計算を行う
covariates = ['age', 'history', 'propensity_score']
balance = pd.DataFrame([
{
'変数': v,
'マッチング前のSMD': round(smd(df[df['treatment'] == 1][v].values,
df[df['treatment'] == 0][v].values), 4),
'マッチング後のSMD': round(smd(matched_treated[v].values,
matched_control[v].values), 4),
}
for v in covariates
])
balance['判定 (|SMD| < 0.1)'] = np.where(balance['マッチング後のSMD'].abs() < 0.1, 'OK', 'NG')
print(balance.to_string(index=False))
- 定義式の実装: 関数 smd は、引用したSMDの定義式をそのまま書き下したものです。分母のプールした分散は不偏分散(ddof=1)で計算しています。
- 前後で同じ関数を使い回す: マッチング前は元データ df を処置群・対照群に分けて、マッチング後は前節で作成した matched_treated と matched_control をそのまま渡しています。式は共通で、渡すデータセットだけが入れ替わります。
- 傾向スコア自身も評価対象に含める: covariates に propensity_score を加えています。スコアが揃っていない状態では共変量が揃うはずもないため、マッチングという操作そのものが機能したかを確認する指標になります。
- 判定基準: 因果推論の実務では
|SMD| < 0.1が均衡の目安とされるため、判定列を付けて機械的に確認できるようにしています。
実行結果
変数 マッチング前のSMD マッチング後のSMD 判定 (|SMD| < 0.1)
age 0.4088 -0.0269 OK
history 0.7500 0.0364 OK
propensity_score 0.9052 0.0023 OK
この結果をLoveプロットとして描画すると、マッチングが何をしたのかが一目で分かります。
cols = ['age', 'history', 'propensity_score']
labels = {'age': '年齢 (age)', 'history': '購買履歴 (history)', 'propensity_score': '傾向スコア'}
before = balance['マッチング前のSMD'].abs().values
after = balance['マッチング後のSMD'].abs().values
fig, ax = plt.subplots(figsize=(8, 3.4))
ypos = np.arange(len(cols))
# 前後の点を線で結び、移動量を見せる
for i in ypos:
ax.plot([after[i], before[i]], [i, i], color='#BBBBBB', lw=1.5, zorder=1)
ax.scatter(before, ypos, s=110, color='#4C72B0', label='マッチング前', zorder=3)
ax.scatter(after, ypos, s=110, color='#DD8452', label='マッチング後', zorder=3)
ax.axvline(0.1, color='red', ls='--', label='目安となる基準 (0.1)')
ax.set_yticks(ypos)
ax.set_yticklabels([labels[c] for c in cols])
ax.set_xlim(0, 1.0)
ax.set_xlabel('標準化平均差の絶対値 |SMD|')
ax.set_title('共変量バランス(Loveプロット)')
ax.grid(axis='x', alpha=0.3)
ax.legend(loc='lower right')
plt.tight_layout()
plt.show()
- 絶対値でのプロット: バランスの評価で関心があるのは「どちらの群が大きいか」ではなく「どれだけずれているか」だけなので、.abs() で符号を落として横軸に取っています。
- 前後を線で結ぶ: マッチング前後の点を ax.plot で結ぶことで、各変数の偏りがどれだけ縮んだかを移動距離として表現しています。共変量が数十個ある実務のケースでも、この図1枚で全変数のバランスを一覧できます。
実行結果

マッチング前は、購買履歴が 0.75、傾向スコアに至っては 0.91 と、基準線 0.1 を大幅に超える偏りがありました。「DMを送られた人」と「送られなかった人」が、そもそも別の集団だったということです。マッチング後はいずれも 0.04 以下に収まり、RCTの記事で「お手本」として確認した状態(Age: 0.020、History: 0.050)と同水準の均衡を、観察データから作り出せたことになります。単純比較の 3303 が 1514 まで補正されたのは、この均衡が達成された結果です。
マッチング後のSMDが良好であることは、あくまでペアになれたデータの中で共変量が均衡していることしか意味しません。以下の2点には注意が必要です。
- 除外されたデータについては何も言えない: キャリパーの条件で弾かれたデータは計算に含まれていません。今回は処置群961件すべてがペアを得られましたが、対照群1039件のうち実際に使われたのは451件(復元抽出による重複を除いたユニーク件数)です。SMDは「残った集団が比較可能か」を示すだけで、失われた代表性までは保証しません。
- 未観測の交絡因子は検出できない: SMDで確認できるのは、モデルに投入した観測済みの共変量だけです。データに存在しない交絡因子が偏っていても、この診断は素通りしてOKを返します。
また、本記事のスクラッチ実装は復元抽出のため、重複して選ばれた対照群のデータをそのまま含めてSMDを計算しています。厳密には、重複回数を重みとして扱った重み付きSMDで評価するのがより適切です(重み付きSMDの定義はIPTW記事で扱います)。
ライブラリを用いた簡単な実装 (CausalInference)
実務では、上記のようなスクラッチ実装よりも、専用の因果推論ライブラリを使用する方がコードが簡潔で、 さらに詳細な統計指標も得られます。ここでは CausalInference ライブラリを用いた例を紹介します。
!pip install causalinference
以下のコードでは、causalinference ライブラリを使用して、これまでスクラッチで実装してきた「傾向スコアの推定」から「マッチングの実行」、そして「効果の推定」までの一連のプロセスをわずか数行で実装します。モデルの初期化と組み込みメソッドの呼び出しだけで、詳細な統計指標を含むサマリーを出力する手順を示します。
from causalinference import CausalModel
# CausalModelの初期化
cm = CausalModel(
Y=df['sales'].values,
D=df['treatment'].values,
X=df[['age', 'history']].values
)
# 傾向スコアの推定
cm.est_propensity_s()
ps = cm.propensity['fitted']
# 【重要】est_via_matching() は既定では共変量Xそのものを距離の基準にするため、
# 傾向スコアマッチングとして使うには、推定した傾向スコアをXとして渡し直す
cm_psm = CausalModel(
Y=df['sales'].values,
D=df['treatment'].values,
X=ps.reshape(-1, 1)
)
# マッチングの実行(bias_adj=True で不完全なマッチによるバイアスを補正)
cm_psm.est_via_matching(bias_adj=True)
# 結果のサマリーを表示
print(cm_psm.estimates)
- CausalModelクラスの初期化: CausalModel クラスを初期化し、引数として目的変数 Y(売上)、処置変数 D(施策の有無)、共変量 X(年齢と購買履歴)を渡して最初のモデル cm を構築します。
- 傾向スコアの自動推定: cm.est_propensity_s() メソッドを呼び出すことで、内部的に最適な変数の組み合わせを探索しながらロジスティック回帰を行い、傾向スコアを自動推定します。算出された各データのスコアは cm.propensity['fitted'] から取得できます。
- 傾向スコアマッチングのための再初期化: ここが重要なポイントです。
CausalInferenceライブラリの est_via_matching() は、既定では初期化時に渡された共変量 X(多次元)の距離を直接用いて共変量マッチングを行います。これを 「傾向スコア(1次元)によるマッチング」 として正しく機能させるため、抽出した傾向スコア ps を新たな共変量 X として渡し、新しいモデル cm_psm を初期化し直しています。 - マッチングとバイアス補正の実行: 新しいモデルに対して cm_psm.est_via_matching(bias_adj=True) を実行し、傾向スコアに基づく最近傍マッチング処理を行います。bias_adj=True を設定することで、ペア間の距離が完全にゼロではない(不完全なマッチング)ことによって生じる微小なバイアスを補正し、より正確な推定を行っています。
- 統計サマリーの出力: 最後に cm_psm.estimates を出力することで、マッチングによって算出された集団全体の平均処置効果(ATE)、対照群における効果(ATC)、処置群における効果(ATT)といった詳細な統計サマリーを一覧で確認することができます。
実行結果
Treatment Effect Estimates: Matching
Est. S.e. z P>|z| [95% Conf. int.]
--------------------------------------------------------------------------------
ATE 1475.102 105.053 14.042 0.000 1269.198 1681.005
ATC 1445.200 122.550 11.793 0.000 1205.001 1685.399
ATT 1507.430 130.499 11.551 0.000 1251.653 1763.208
CausalInference を使うと、数行のコードで傾向スコアの算出からマッチング、そして効果(ATE, ATT, ATCなど)の推定までを一貫して行うことができます。
なお、ここで出力されている ATE は「集団全体に施策を適用した場合の効果」を表しますが、マッチングは本来 ATT(実際に施策を受けた層への効果)を得意とする手法です。CausalInferenceの出力するATEは、処置群→対照群のマッチング(ATT)と対照群→処置群のマッチング(ATC)を群のサイズで加重平均したものに過ぎないため、マッチングによるATEはATTに比べて信頼性が落ちる場合があります。
もしデータを捨てずに集団全体に対する ATE を直接推定したい場合は、傾向スコアの逆数を重みとして用いる 逆確率重みづけ(IPTW) という手法がより適しています。参考までに、同じダミーデータに対する IPTW の推定値(ATE)は 1451.11 となり、本記事の 1475.10 と近い結果が得られます。
傾向スコアマッチングを成立させる3つの前提条件
傾向スコアを用いた因果推論が正しく機能するためには、以下の3つの仮定(前提条件)を満たしている必要があります。
- 強く無視できる割り当て(Strongly Ignorable Treatment Assignment / 条件付き独立性)
手元にある共変量 を条件付けた(統制した)とき、施策の割り当て と潜在的結果変数 が独立である、という仮定です。要するに、「結果に影響を与える交絡因子はすべてデータとして観測できている(未観測の交絡因子は存在しない)」 という前提です。 - 正値性(Positivity / 共通サポート)
すべてのユーザーに対して、施策を受ける確率も受けない確率もゼロではない()という仮定です。つまり、施策群と対照群で傾向スコアの分布が重なる部分(共通サポート領域)が存在しなければ、マッチングによる比較ができません。 - SUTVA(Stable Unit Treatment Value Assumption:安定単位処置値の仮定)
「あるユーザーへの施策が、他のユーザーの結果に影響を与えない(波及効果・スピルオーバーがない)」かつ「施策の内容は誰に対しても一定である」という仮定です。SNSの口コミキャンペーンなど、ネットワーク効果がある施策ではこの仮定が崩れやすくなります。
傾向スコアマッチングのメリットとデメリット
傾向スコアマッチングは実務で非常に強力な武器となりますが、特性を理解して利用する必要があります。
メリット:
-
説明のしやすさ:
ステークホルダーに対して「条件(スコア)が似ている人同士をペアにして比較しました」と説明できるため、直感的で納得感を得やすいという強みがあります。 -
モデルの誤設定に対する頑健性:
マッチング設計は、事後マッチング解析におけるモデルの誤設定に対する依存性を減らすことができます。
デメリット:
-
データが捨てられるリスク:
ペアになれなかったデータ(条件に合う相手がいなかったデータ)は分析から除外されるため、サンプルサイズが小さくなり、統計的なパワーが落ちる可能性があります。 -
未観測の交絡因子には無力:
上記の前提条件「強く無視できる割り当て」で触れた通り、傾向スコアは「観測されているデータ」にのみ依存します。結果に大きな影響を与える変数が取得できていない場合、バイアスは残ったままになります。
PSMパラドックスについて
近年、King & Nielsen (2019) らによって「傾向スコアマッチングにおいて、マッチングの基準(キャリパー)を狭めすぎると逆に不均衡が拡大し、バイアスが増える」という「PSMパラドックス」の存在が主張され、因果推論の分野で大きな議論を呼びました。
しかし、その後の研究によって、この主張には別の視点からの解釈や反論が寄せられています。 たとえば Wan (2025) は、このパラドックスが「偶然生じる不均衡」を適切に捉えられない評価指標の誤用に起因すると指摘しました。傾向スコアが同一のペアであっても個々の共変量の値は偶然によってばらつきますが、その差はペア数が増えれば平均的にゼロへ収束します。ところが従来用いられてきたマハラノビス距離などの指標は差の符号を考慮しないため、キャリパーを厳しくしてサンプルサイズが減少すると、この「偶然のばらつき」が不均衡の増大として見えてしまう、という説明です。
また、Ripollone et al. (2018) は実際の医療クレームデータを用いた検証を行い、段階的な刈り込みによって不均衡が増大する現象自体は観測されるものの、実務で一般的に用いられるキャリパー基準を適用する範囲においては、マッチング前より不均衡が十分に改善することを示しています。
PSMパラドックスについては現在も議論が続いており、留意すべき論点であることは間違いありません。しかし、「傾向スコアマッチング自体が使えない手法である」という極端な結論には至っていません。実務においては他の手法(因果フォレストやIPTWなど)と適切に使い分けるか、併用して結果のロバスト性(頑健性)を確認するアプローチが推奨されます。
まとめ
本記事では、観察データからセレクションバイアスを取り除く手法として、傾向スコアマッチング(PSM) を解説しました。
重要なポイントは以下の通りです。
- 多次元の共変量を1つのスコアに要約: 傾向スコアは「施策を受ける確率」であり、複雑な共変量 を1次元に圧縮するバランシングスコアとして機能します。
- 似た者同士のペアで比較: スコアが近いユーザーをマッチングすることで、共変量の分布が均衡した比較可能な集団を作り出します。今回のダミーデータでは、単純比較で 3303 と過大評価されていた効果が、マッチング後には真の効果 1500 に近い 1514 として推定できました。
- バランスの検証は必須の工程: 効果を報告する前に、標準化平均差(SMD)でマッチング前後の共変量バランスを確認します。今回のデータでは、マッチング前に 0.41〜0.91 あった偏りが、マッチング後には 0.04 以下(基準は 0.1)まで縮小しました。
- 算出されるのは主に ATT: 施策群を基準にマッチングするため、得られるのは「実際に施策を受けた層への効果(ATT)」です。マーケティング実務の関心と合致しやすい指標です。
- 成立には3つの仮定が必要: 強く無視できる割り当て・正値性・SUTVA が前提であり、特に未観測の交絡因子が存在する場合、傾向スコアでは調整できません。
- 手法選択は目的次第: データを捨てたくない場合や集団全体への効果(ATE)を知りたい場合は、逆確率重みづけ(IPTW) などの手法が適しています。
正しく仮定を置き、適切なモデリングを行うことで、傾向スコアマッチングは因果推論の強力なツールとして機能します。
本記事の文章・構成の一部に生成AIを使用しています。