Eksperimen acak adalah standar emas, tetapi chapter sebelumnya sudah menyebut kenapa tidak semua pertanyaan sampai ke sana: program loyalitas yang sudah berjalan bertahun-tahun tidak bisa diacak ulang, dan mengundi pengguna untuk menerima harga atau layanan berbeda sering berada di luar batas kebijakan. Data observasional — para pelanggan yang sendirinya memilih ikut program — tetap harus dianalisis, dan di situlah propensity score matching bekerja: mengestimasi peluang setiap pelanggan menjadi peserta berdasarkan kovariatnya, lalu membandingkan peserta hanya dengan non-peserta yang profile-nya nyaris sama. Chapter ini membangun seluruh alurnya secara manual — skor kecenderungan dari LogisticRegression, pencocokan nearest-neighbor dengan caliper di NumPy murni, pemeriksaan keseimbangan kovariat, dan estimasi ATT — pada data sintetis yang efek sejatinya kita tanam sendiri, sehingga keberhasilan metode ini bisa dinilai dengan angka, bukan dengan keyakinan.

Skor Kecenderungan: Satu Angka untuk Seluruh Profil #

Masalah pencocokan langsung pada empat kovariat adalah dimensionalitas: mencari pasangan yang mirip pada usia, pendapatan, frekuensi kunjungan, dan jumlah perangkat sekaligus praktis mustahil saat kovariatnya belasan. Hasil klasik Rosenbaum dan Rubin (1983) menyederhanakannya: cukup mencocokkan pada satu angka, skor kecenderungan e(X) = P(ikut = 1 | X) — peluang menjadi peserta diberikan profil kovariat. Bila dua pelanggan punya skor yang sama, profil kovariat mereka terdistribusi sama, sehingga membandingkan keduanya menyerupai membandingkan dua lengan eksperimen yang diacak.

Skor itu sendiri adalah peluang bersyarat yang tidak diketahui, jadi kita mengestimasinya — dan di sinilah machine learning masuk ke analisis kausal. Pekerjaan paling standar adalah regresi logistik pada kovariat terstandarisasi, dengan LogisticRegression dari scikit-learn; untuk kovariat yang lebih rumit, model berbasis pohon juga lazim dipakai. Penting dicatat sejak awal: model skor yang baik adalah model yang menyeimbangkan kovariat setelah pencocokan, bukan model yang paling akurat memprediksi status peserta — evaluasinya bukan AUC, melainkan tabel keseimbangan yang akan kita bangun di bawah.

Pencocokan Nearest-Neighbor dengan Caliper #

Dengan skor di tangan, algoritma pencocokannya jujur dan bisa ditulis dalam dua puluh baris. Untuk setiap peserta, cari non-peserta dengan skor kecenderungan paling dekat — pada skala logit skor, bukan skor mentah, karena jarak pada logit lebih setara di seluruh rentang peluang — lalu jadikan pasangan, dengan dua pengaman. Pertama, caliper: pasangan hanya sah bila jarak skornya tidak melebihi 0,2 simpangan baku logit skor; peserta yang tidak menemukan pasangan dalam caliper dibiarkan tidak terpakai, karena tidak ada pembanding yang kredibel untuknya (ini sisi praktis asumsi positivity dari Chapter Framework Potential Outcomes). Kedua, tanpa pengembalian: satu non-peserta hanya dipakai satu kali, sehingga pasangan yang cocok tidak dipakai ulang untuk memanipulasi estimasi.

Hasil pencocokan semacam ini secara alami mengestimasi ATT — efek rata-rata bagi para peserta — karena yang dibandingkan adalah peserta (semua yang berhasil dicocokkan) dengan kembarannya yang tidak ikut. Pertanyaan “berapa efek program bagi orang yang memang memilih ikut” ini biasanya persis pertanyaan evaluasi program, sehingga ATT adalah estimand yang wajar; ATE — efek bila seluruh populasi dipaksa ikut — menuntut ekstrapolasi ke wilayah skor tanpa pembanding.

Contoh Kode: Empat Langkah dari Data Bias ke Estimasi #

Data sintetisnya memuat 700 pelanggan dengan empat kovariat; penugasan program tidak acak (pelanggan aktif dan mapan lebih mungkin ikut) dan belanja bulanan dipengaruhi keempat kovariat plus efek program +12 ratus ribu rupiah yang kita tanam sendiri:

python
# Propensity score matching manual: estimasi efek program upgrade dari data
# observasional. Skor kecenderungan dilatih dengan LogisticRegression,
# pencocokan nearest-neighbor dengan caliper dilakukan di NumPy murni,
# lalu keseimbangan kovariat diperiksa lewat standardized mean difference.

import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler

rng = np.random.default_rng(42)

n = 700
usia = rng.integers(25, 61, n).astype(float)          # tahun
pendapatan = rng.uniform(4.0, 15.0, n)                # juta rupiah/bulan
kunjungan = rng.integers(2, 21, n).astype(float)      # kunjungan situs/bulan
perangkat = rng.integers(1, 5, n).astype(float)       # jumlah perangkat aktif

# Penugasan tidak acak: pelanggan aktif dan mapan lebih mungkin ikut program.
logit_w = -6.5 + 0.04 * usia + 0.15 * kunjungan + 0.12 * pendapatan + 0.4 * perangkat
p_w = 1.0 / (1.0 + np.exp(-logit_w))
ikut = (rng.random(n) < p_w).astype(int)

# Hasil: belanja bulanan (ratus ribu rupiah). Efek program sejati = +12.
belanja = (20.0 + 2.2 * kunjungan + 3.0 * pendapatan + 0.4 * usia
           + 1.0 * perangkat + 12.0 * ikut + rng.normal(0.0, 8.0, n))

df = pd.DataFrame({
    "usia": usia, "pendapatan": pendapatan, "kunjungan": kunjungan,
    "perangkat": perangkat, "ikut": ikut, "belanja": belanja,
})
fitur = ["usia", "pendapatan", "kunjungan", "perangkat"]

naif = df.loc[df.ikut == 1, "belanja"].mean() - df.loc[df.ikut == 0, "belanja"].mean()
print(f"Selisih naif (semua peserta vs semua non-peserta): {naif:+.2f}")
print(f"Efek sejati yang ditanam pada data                : +12.00")

# --- Langkah 1: skor kecenderungan P(ikut=1 | kovariat) ---
skala = StandardScaler()
X_std = skala.fit_transform(df[fitur])
model_ps = LogisticRegression(max_iter=1000, random_state=42)
model_ps.fit(X_std, df["ikut"])
skor = model_ps.predict_proba(X_std)[:, 1]
logit_skor = model_ps.decision_function(X_std)

print(f"\nSkor kecenderungan peserta    : min {skor[ikut == 1].min():.3f}, "
      f"rata-rata {skor[ikut == 1].mean():.3f}, max {skor[ikut == 1].max():.3f}")
print(f"Skor kecenderungan non-peserta: min {skor[ikut == 0].min():.3f}, "
      f"rata-rata {skor[ikut == 0].mean():.3f}, max {skor[ikut == 0].max():.3f}")

# --- Langkah 2: pencocokan nearest-neighbor dengan caliper ---
idx_peserta = np.where(df["ikut"].values == 1)[0]
idx_non = np.where(df["ikut"].values == 0)[0]
jarak = np.abs(logit_skor[idx_peserta][:, None] - logit_skor[idx_non][None, :])
caliper = 0.2 * np.std(logit_skor)  # aturan umum: 0,2 SD logit skor

jarak_tersedia = jarak.copy()
pasangan = []
for i in range(len(idx_peserta)):
    j = int(np.argmin(jarak_tersedia[i]))        # tetangga terdekat pertama
    if jarak_tersedia[i, j] <= caliper:
        pasangan.append((idx_peserta[i], idx_non[j]))
        jarak_tersedia[:, j] = np.inf            # kontrol itu sudah terpakai

idx_p = np.array([p for p, _ in pasangan])
idx_k = np.array([k for _, k in pasangan])
print(f"\nPeserta berhasil dicocokkan : {len(pasangan)} dari {len(idx_peserta)}")
print(f"Caliper                      : {caliper:.3f} (0,2 SD logit skor)")

# --- Langkah 3: keseimbangan kovariat sebelum vs sesudah ---
def smd(x1, x0):
    return (x1.mean() - x0.mean()) / np.sqrt((x1.var(ddof=1) + x0.var(ddof=1)) / 2.0)

print(f"\n{'kovariat':<12s} {'SMD sebelum':>12s} {'SMD sesudah':>12s}   status")
for f in fitur:
    sebelum = smd(df.loc[df.ikut == 1, f].values, df.loc[df.ikut == 0, f].values)
    sesudah = smd(df.iloc[idx_p][f].values, df.iloc[idx_k][f].values)
    status = "seimbang" if abs(sesudah) < 0.1 else "belum seimbang"
    print(f"{f:<12s} {sebelum:>+12.3f} {sesudah:>+12.3f}   {status}")

# --- Langkah 4: estimasi ATT dari pasangan yang cocok ---
att = float(np.mean(df["belanja"].values[idx_p] - df["belanja"].values[idx_k]))
print(f"\nATT hasil pencocokan : {att:+.2f}  (target sejati: +12.00)")
print(f"Selisih naif tadi     : {naif:+.2f}  -> jauh dari target")

Output yang Dihasilkan:

text
Selisih naif (semua peserta vs semua non-peserta): +23.91
Efek sejati yang ditanam pada data                : +12.00

Skor kecenderungan peserta    : min 0.060, rata-rata 0.438, max 0.876
Skor kecenderungan non-peserta: min 0.034, rata-rata 0.275, max 0.846

Peserta berhasil dicocokkan : 198 dari 230
Caliper                      : 0.200 (0,2 SD logit skor)

kovariat      SMD sebelum  SMD sesudah   status
usia               +0.321       -0.057   seimbang
pendapatan         +0.357       +0.015   seimbang
kunjungan          +0.612       -0.005   seimbang
perangkat          +0.374       +0.058   seimbang

ATT hasil pencocokan : +11.27  (target sejati: +12.00)
Selisih naif tadi     : +23.91  -> jauh dari target

Mari kita bedah empat langkahnya sesuai penanda di kode:

  1. Baris 34–36 (pembacaan naif): selisih rata-rata seluruh peserta lawan seluruh non-peserta — angka yang akan keluar bila kita langsung membaca dashboard.
  2. Baris 39–49 (Langkah 1 — skor): StandardScaler menyetarakan skala kovariat, LogisticRegression(max_iter=1000, random_state=42) melatih model skor, predict_proba memberi skor mentah e(X), dan decision_function memberi logit-nya untuk dijadikan basis jarak; ringkasan min/rata-rata/max per grup memperlihatkan tumpang-tindih kedua distribusi.
  3. Baris 54–63 (Langkah 2 — pencocokan): baris 54 membangun matriks jarak peserta-×-non-peserta, baris 55 menetapkan caliper 0,2 SD, dan loop baris 59–63 melakukan pencarian serakah: np.argmin memilih tetangga terdekat pertama, pasangan sah hanya bila dalam caliper, dan kolom non-peserta yang sudah terpakai diset np.inf agar tidak dipilih dua kali.
  4. Baris 71–79 (Langkah 3 — keseimbangan): fungsi smd menghitung standardized mean difference — selisih rata-rata dibagi akar rata-rata varians kedua grup — untuk tiap kovariat, sebelum dan sesudah pencocokan; ambang praktis 0,1 menjadi penanda seimbang.
  5. Baris 82–84 (Langkah 4 — estimasi): ATT dihitung sebagai rata-rata selisih belanja pada 198 pasangan yang terbentuk.

Angka-angkanya menceritakan transformasi yang utuh. Pembacaan naif +23,91 — hampir dua kali efek sejati +12,00, karena peserta program memang pelanggan yang sudah belanja lebih besar sejak awal. Pencocokan berhasil mempertemukan 198 dari 230 peserta; 32 peserta tidak menemukan kembaran dalam caliper dan ditinggalkan — harga yang dibayar untuk kejujuran, karena memaksa pencocokan pada wilayah tanpa tumpang-tindih justru mengarang pembanding. Tabel keseimbangan menunjukkan kerja skor kecenderungan dengan jelas: SMD sebelum pencocokan 0,321 / 0,357 / 0,612 / 0,374 untuk usia, pendapatan, kunjungan, dan perangkat — semuanya jauh dari seimbang — turun menjadi −0,057 / +0,015 / −0,005 / +0,058, seluruhnya di bawah ambang 0,1. Dari pasangan yang seimbang itu, ATT hasil pencocokan +11,27: berjarak 0,73 dari kebenaran yang kita tanam, padahal pembacaan naif meleset 11,91.

Batasan yang Harus Dibawa Pulang #

Kekuatan dan kelemahan propensity score berada di tempat yang sama. Skor menyeimbangkan kovariat yang terukur dan dimasukkan ke model — tidak lebih. Konfounder yang tidak ada di data (niat belanja, tekanan harga dari kompetitor) tidak ikut terseimbangkan, sehingga asumsi unconfoundedness dari kerangka potential outcomes tetap dipertaruhkan penuh; yang berubah hanyalah cakupan asumsinya, dari “semua perbedaan grup” menjadi “perbedaan yang tidak tercermin pada kovariat terukur”. Selain itu, model skor yang keliru bentuk (misalnya hubungan non-linear yang dipaksa linear) meninggalkan sisa ketidakseimbangan — tabel SMD adalah alat kontrolnya, dan bila satu baris masih di atas 0,1, model skor perlu diperbaiki sebelum estimasi dibaca.

Dua konsekuensi desain juga perlu diakui. Pencocokan membuang data: 32 peserta tanpa pasangan hilang dari estimasi, dan estimand bergeser menjadi ATT bagi peserta yang bisa dicocokkan — bukan ATT bagi seluruh peserta. Pencocokan satu-ke-satu juga hanya memakai setiap non-peserta sekali, menyisakan ratusan pembanding tak terpakai. Metode berbasis pembobotan (weighting) memakai seluruh sampel, tetapi membawa masalah bobot ekstrem sendiri. Pertanyaan yang tersisa: bisakah kita menyeimbangkan konfounder nonlinear tanpa membuang data dan tanpa memaksa seluruh struktur ke satu angka skor? Jawaban modern untuk itu — meminjam kekuatan model machine learning untuk menghapus bagian hasil dan treatment yang bisa dijelaskan kovariat, lalu membandingkan sisanya — adalah materi Chapter Double Machine Learning.

Referensi Resmi #