{ "cells": [ { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "# 23 - Challenges with Effect Heterogeneity and Nonlinearity\n", "\n", "인과추론의 어려운 점은 `potential outcome`의 모든 결과를 알 수 없다는 것입니다. `unit` 수준의 처치 효과(`ground truth`)를 알 방법이 없습니다. 앞 장에서 조건부 처치 효과(`CATE`)를 추정하기 위해 `target transformation`(목표 변환)을 사용하는 방법을 배웠습니다. 목표 변환에서는 과적합으로 인한 문제가 있었습니다. 이는 얻은 인과 모델을 적용할 수 없는 이유가 되기도 하는데, 처치를 개인화하려 할 때 잘못된 결론을 줄 수 있기 때문입니다.\n", "\n", "`CATE`를 직접 추정하지 않는 대신 분산이 작은 `target`에 집중한다면 종종 더 나은 방법으로 처치 효과를 개인화할 수 있습니다. 특히 `outcome` $Y$가 `binary`일 때 유용합니다.\n", "\n", "## Treatment Effects on Binary Outcomes" ] }, { "cell_type": "code", "execution_count": 1, "metadata": { "tags": [ "hide-input" ] }, "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd\n", "import seaborn as sns\n", "from toolz import curry\n", "from matplotlib import pyplot as plt\n", "from matplotlib import style\n", "style.use(\"ggplot\")\n", "\n", "@curry\n", "def avg_treatment_effect(df, treatment, outcome):\n", " return df.loc[df[treatment] == 1][outcome].mean() - df.loc[df[treatment] == 0][outcome].mean() \n", "\n", "@curry\n", "def cumulative_effect_curve(df: pd.DataFrame,\n", " treatment: str,\n", " outcome: str,\n", " prediction: str,\n", " min_rows: int = 30,\n", " steps: int = 100,\n", " effect_fn = avg_treatment_effect) -> np.ndarray:\n", " \n", " size = df.shape[0]\n", " ordered_df = df.sort_values(prediction, ascending=False).reset_index(drop=True)\n", " n_rows = list(range(min_rows, size, size // steps)) + [size]\n", " return np.array([effect_fn(ordered_df.head(rows), treatment, outcome) for rows in n_rows])\n", "\n", "@curry\n", "def cumulative_gain_curve(df: pd.DataFrame,\n", " treatment: str,\n", " outcome: str,\n", " prediction: str,\n", " min_rows: int = 30,\n", " steps: int = 100,\n", " effect_fn = avg_treatment_effect) -> np.ndarray:\n", "\n", " size = df.shape[0]\n", " n_rows = list(range(min_rows, size, size // steps)) + [size]\n", "\n", " cum_effect = cumulative_effect_curve(df=df, treatment=treatment, outcome=outcome, prediction=prediction,\n", " min_rows=min_rows, steps=steps, effect_fn=effect_fn)\n", "\n", " return np.array([effect * (rows / size) for rows, effect in zip(n_rows, cum_effect)])\n" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "테크 기업에서 직면하는 일반적인 문제를 생각해봅시다. 경영진은 일종의 넛지 효과로 제품 구매율을 높이고 싶습니다. 예를 들어 인앱 결제로 편하게 제품을 구매하도록 $10$ BRL만큼 혜택을 제공해 앱 설치 수를 늘리거나, 추천인에 대한 혜택을 제공하거나, 처음 3개월 동안 무료 배송 혜택을 주려 합니다. 넛지 효과를 만드는 방법은 비용이 많이 들어 경영진은 일부 민감한 고객에게만 혜택을 주려 합니다.\n", " \n", "인과 추론에 능한 여러분이라면 이 문제의 목표가 `treatment effect heterogeneity`(`TEH`, 처치 효과 이질성)를 구하는 것임을 이미 아실겁니다. `treatment` $T$는 넛지 효과를 주는 행위, `outcome` $Y$는 구매 전환, `feature` $X$는 사전에 가지고 있는 고객 데이터입니다. `Double/Debiased ML` 같은 방법으로 조건부 평균 처치 효과 $E[Y_1 - Y_0|X]$ (처치가 연속이면 $E[Y'(T)|X]$)를 추정하고 처치 효과가 큰 고객을 대상으로 구매를 독려할 수 있습니다. 비즈니스 용어로는 **전환 전략을 개인화**할 수 있습니다. 즉, 구매 전환율이 높은 고객을 대상으로만 넛지 전략을 사용합니다.\n", " \n", "하지만 `outcome`이 `binary`일 때는 `TEH`를 구하기가 어렵습니다. 바로 이해하기는 어렵습니다. 어떤 문제가 발생하는지 먼저 보고 왜 문제가 발생하는지 말씀드리겠습니다.\n", "\n", "## Simulating Some Data\n", " \n", "시뮬레이션으로 데이터를 만들어 봅시다. `treatment`인 `nudge`를 $p=0.5$인 베르누이 분포에서 추출하여 무작위로 할당합니다. 마치 동전 던지기와 같습니다. 무작위 할당이므로 교란 요인은 더 이상 생각하지 않아도 됩니다.\n", " \n", "$ nudge \\sim \\mathcal{B}(0.5) $\n", " \n", "다음은 감마 분포를 사용해 고객의 `covariate`(공변량)인 `age`와 `income`을 할당합니다. `age`와 `income`은 이미 알고 있는 고객 정보로 이를 기반으로 개인화하고자 합니다. 즉, `age`와 `income`으로 정의된 고객 그룹 중 `nudge`에 민감한 그룹을 찾습니다.\n", " \n", "$ age \\sim G(10, 4) $\n", " \n", "$ income \\sim G(20, 2) $\n", " \n", "마지막으로 구매 전환 데이터를 시뮬레이션합니다. 데이터를 만들기 위해 **무작위 잡음이 포함된 선형 잠재 변수를 만듭니다.** 중요한 점은 `income`은 $Y_{latent}$을 잘 따르지만 **처치 효과를 바꾸지 않는다는 것입니다.** 간단히 말해 `nudge`는 모든 수준의 `income`에 대해 $Y_{latent}$를 똑같이 바꿉니다. 반대로 `age`는 `nudge`와 상호작용하는 방법으로만 $Y_{latent}$에 영향을 줍니다. $Y_{latent}$는 아래 수식과 같이 평균이 `age`, `income`, `nudge`의 선형 결합, 분산이 $1$인 정규분포를 따릅니다.\n", " \n", "$Y_{latent} \\sim N(-4.5 + 0.001 \\times income + nudge + 0.01 \\times nudge \\times age, 1)$\n", " \n", "$Y_{latent}$를 구하고 기준값 $x$를 사용해 `conversion`을 얻을 수 있습니다. ($conversion = 1\\{Y_{latent} > x \\}$). `conversion` 비율이 대략 $50$%가 되도록 $x=0$으로 설정합니다. 즉, 평균적으로 50%의 고객은 `nudge`가 구매로 이어집니다." ] }, { "cell_type": "code", "execution_count": 2, "metadata": {}, "outputs": [], "source": [ "np.random.seed(123)\n", "\n", "n = 100000\n", "nudge = np.random.binomial(1, 0.5, n)\n", "age = np.random.gamma(10, 4, n)\n", "estimated_income = np.random.gamma(20, 2, n)*100\n", "\n", "latent_outcome = np.random.normal(-4.5 + estimated_income*0.001 + nudge + nudge*age*0.01)\n", "conversion = (latent_outcome > .1).astype(int)" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "편의상 모든 데이터를 데이터프레임으로 만듭니다. 추가로 `conversion` 평균값이 실제로 $50$%에 가까운지 확인해봅시다." ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "conversion 0.518260\n", "nudge 0.500940\n", "age 40.013487\n", "estimated_income 3995.489527\n", "latent_outcome 0.197076\n", "dtype: float64" ] }, "execution_count": 3, "metadata": {}, "output_type": "execute_result" } ], "source": [ "df = pd.DataFrame(dict(conversion=conversion,\n", " nudge=nudge,\n", " age=age,\n", " estimated_income=estimated_income,\n", " latent_outcome=latent_outcome))\n", "\n", "df.mean()" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "처치가 무작위이므로 `ATE`는 실험군과 대조군의 단순한 평균 차이로 추정할 수 있습니다. ($E[Y|T=1] - E[Y|T=0]$) 먼저 `latent_outcome`, `conversion`에 대한 `ATE`를 계산합니다." ] }, { "cell_type": "code", "execution_count": 4, "metadata": {}, "outputs": [ { "data": { "text/html": [ "
\n", " | latent_outcome | \n", "conversion | \n", "
---|---|---|
nudge | \n", "\n", " | \n", " |
0 | \n", "-0.505400 | \n", "0.320503 | \n", "
1 | \n", "0.896916 | \n", "0.715275 | \n", "