23  효과 이질성과 비선형성

개별 대상 수준에서 처치효과를 예측하기란 무척 어렵습니다. 참값(ground truth)이 없기 때문입니다. 잠재적 결과 \(T(t)\) 중 하나만 관측할 수 있으니 처치효과를 직접 추정할 수는 없습니다. 그 대신 목표 변환(target transformation)에 기대야 하고(목표 변환은 영리하게 설계한 손실 함수로 볼 수도 있습니다), 그마저도 조건부 처치효과를 기댓값으로만 추정할 수 있습니다. 어려움은 이것만이 아닙니다. 처치효과는 워낙 종잡을 수 없어서 그 추정량에 잡음이 상당히 많이 끼곤 합니다. 개인화된 처치 배정처럼 처치효과에 따라 대상을 나누려는 응용 분야에서는 이 점이 실무적으로 아주 큰 문제가 됩니다.

이번 장에서는 이런 경우를 살펴봅니다. 조건부 평균 처치효과(CATE)를 직접 추정하지 않고 대개 분산이 더 작은 다른 대리 목표에 집중하면, 오히려 처치효과에 따라 대상을 더 잘 나눌 수 있는 경우입니다. 관심 있는 결과변수 \(Y\)가 이진(binary)일 때 이런 일이 흔히 일어납니다.

이진 결과에서의 처치효과

코드
import pandas as pd
import numpy as np
import seaborn as sns
import statsmodels.formula.api as smf
from matplotlib import pyplot as plt
from matplotlib import style
style.use("ggplot")
코드
from typing import List

import numpy as np
import pandas as pd
from toolz import curry, partial

@curry
def avg_treatment_effect(df, treatment, outcome):
    return df.loc[df[treatment] == 1][outcome].mean() - df.loc[df[treatment] == 0][outcome].mean()
    
    

@curry
def cumulative_effect_curve(df: pd.DataFrame,
                            treatment: str,
                            outcome: str,
                            prediction: str,
                            min_rows: int = 30,
                            steps: int = 100,
                            effect_fn = avg_treatment_effect) -> np.ndarray:
    
    size = df.shape[0]
    ordered_df = df.sort_values(prediction, ascending=False).reset_index(drop=True)
    n_rows = list(range(min_rows, size, size // steps)) + [size]
    return np.array([effect_fn(ordered_df.head(rows), treatment, outcome) for rows in n_rows])


@curry
def cumulative_gain_curve(df: pd.DataFrame,
                          treatment: str,
                          outcome: str,
                          prediction: str,
                          min_rows: int = 30,
                          steps: int = 100,
                          effect_fn = avg_treatment_effect) -> np.ndarray:
    

    size = df.shape[0]
    n_rows = list(range(min_rows, size, size // steps)) + [size]

    cum_effect = cumulative_effect_curve(df=df, treatment=treatment, outcome=outcome, prediction=prediction,
                                         min_rows=min_rows, steps=steps, effect_fn=effect_fn)

    return np.array([effect * (rows / size) for rows, effect in zip(n_rows, cum_effect)])

테크 기업에서 일하다 보면 정말 흔하게 마주치는 문제가 있습니다. 경영진이 일종의 넛지(nudge)를 써서 제품의 고객 전환(conversion)을 끌어올리고 싶어 하는 경우입니다. 예를 들어 인앱 구매에 쓸 수 있는 10헤알(BRL)짜리 바우처를 주어 앱 설치 수를 늘리려 할 수 있습니다. 차량 공유 앱을 처음 쓸 때 한 번 무료로 태워 줄 수도 있고, 투자 플랫폼에서 첫 3개월 동안 거래 수수료를 깎아 줄 수도 있습니다. 넛지에는 대개 돈이 많이 들기 때문에 경영진은 모든 고객에게 넛지를 주지 않아도 되기를 바랍니다. 전환을 끌어올리는 넛지를 거기에 가장 민감하게 반응하는 고객에게만 줄 수 있다면 더할 나위 없을 것입니다.

지금쯤이면 짐작하겠지만, 인과추론의 관점에서 이런 비즈니스 문제는 처치효과 이질성(treatment effect heterogeneity, TEH)이라는 큰 범주에 속합니다. 구체적으로 말하면 비용이 드는 넛지가 처치 \(T\), 전환이 이진 결과 \(Y\), 처치 전에 측정한 고객별 특성이 \(X\)입니다. 그러면 이중/편향 제거 머신러닝(Double/Debiased ML) 같은 방법으로 조건부 평균 처치효과 \(E[Y_1 - Y_0|X]\)(처치가 연속형이라면 \(E[Y'(T)|X]\))를 추정하고, 추정된 처치효과가 가장 높은 고객에게만 넛지를 주면 됩니다. 비즈니스 용어로 말하면 전환 전략을 개인화하는 것입니다. 넛지를 주었을 때 전환이 크게 늘어나는(증분이 큰) 고객 세그먼트를 찾아 그 고객에게만 넛지를 쓰는 셈입니다.

그런데 TEH 접근법이 생각만큼 간단하지 않은 이유가 하나 있습니다. 결과가 이진이면 일이 상당히 복잡해집니다. 다소 직관에 어긋나는 내용이라서, 무슨 일이 일어나는지 먼저 보여 드리고 왜 그런지는 나중에 설명하겠습니다.

데이터 시뮬레이션하기

아주 단순하면서도 현실과 동떨어지지 않은 예를 만들어 보겠습니다. 처치인 nudge는 \(p=0.5\)인 베르누이 분포에서 뽑아 완전히 무작위로 배정되도록 시뮬레이션합니다. 공정한 동전을 던져 처치를 배정한다는 뜻입니다. 따라서 신경 써야 할 교란도 없습니다.

\(nudge \sim \mathcal{B}(0.5)\)

다음으로 고객의 공변량인 age(나이)와 income(소득)을 감마 분포에서 시뮬레이션합니다. 이 두 변수는 고객에 관해 알고 있는 정보이므로 이를 바탕으로 개인화하려고 합니다. 다시 말해 나이와 소득으로 고객 그룹을 나누되, 그중 한 그룹이 nudge 처치에 크게 반응하도록 나누는 방법을 찾으려는 것입니다.

\(age \sim G(10, 4)\)

\(income \sim G(20, 2)\)

마지막으로 전환을 시뮬레이션합니다. 이를 위해 먼저 무작위 잡음이 섞인 선형 모델을 따르는 잠재변수(latent variable)를 만듭니다. 여기서 중요한 점이 있습니다. income은 \(Y_{latent}\)를 아주 잘 예측하지만 처치효과를 바꾸지는 않습니다. 쉽게 말해 넛지는 income 수준과 관계없이 \(Y_{latent}\)를 똑같이 올립니다. 반대로 age는 nudge 처치와의 상호작용을 통해서만 \(Y_{latent}\)에 영향을 줍니다.

\(Y_{latent} \sim N(-4.5 + 0.001 \ income + nudge + 0.01 \ nudge \ age, 1)\)

\(Y_{latent}\)를 만들었으면 conversion을 \(Y_{latent}>x\)로 정해 시뮬레이션할 수 있습니다. 우선 전환율이 대략 50%가 되도록 x=0으로 둡니다. 평균적으로 고객의 50%가 제품으로 전환한다는 뜻입니다.

\(conversion = 1\{Y_{latent} > 0\}\)

힌트옮긴이

이 장에서 ’잠재 결과(latent outcome)’는 전환 여부를 결정하는 관측되지 않는 연속형 잠재변수(코드의 latent_outcome)를 가리킵니다. 처치 여부에 따른 결과를 뜻하는 ’잠재적 결과(potential outcome)’와는 다른 개념입니다.

np.random.seed(123)

n = 100000
nudge = np.random.binomial(1, 0.5, n)
age = np.random.gamma(10, 4, n)
estimated_income = np.random.gamma(20, 2, n)*100

latent_outcome = np.random.normal(-4.5 + estimated_income*0.001 + nudge + nudge*age*0.01)
conversion = (latent_outcome > .1).astype(int)

편의를 위해 모든 값을 데이터프레임에 담아 두겠습니다. 평균 전환율이 정말 50%에 가까운지도 확인해 봅시다.

df = pd.DataFrame(dict(conversion=conversion,
                       nudge=nudge,
                       age=age,
                       estimated_income=estimated_income,
                       latent_outcome=latent_outcome))

df.mean()
conversion             0.518260
nudge                  0.500940
age                   40.013487
estimated_income    3995.489527
latent_outcome         0.197076
dtype: float64

평균 처치효과는 처치가 무작위로 배정되었으므로 실험군과 대조군의 단순 평균 차이 \(E[Y|T=1] - E[Y|T=0]\)로 추정할 수 있습니다. 그러면 처치 여부별 평균이 어떻게 나오는지 보겠습니다. 잠재 결과와 전환 두 관점에서 모두 살펴봅니다. 여기서 눈여겨볼 중요한 점이 있습니다.

df.groupby("nudge")[["latent_outcome", "conversion"]].mean()
latent_outcome conversion
nudge
0 -0.505400 0.320503
1 0.896916 0.715275
avg_treatment_effect(df, "nudge", "latent_outcome")
1.4023163965477656
avg_treatment_effect(df, "nudge", "conversion")
0.39477273768476406

잠재 결과에 대한 ATE는 꽤 단순합니다. 데이터 생성 모델을 보면 이 효과는 1 + avg(age)*0.01이어야 합니다. 평균 나이가 40 정도이므로 ATE는 약 1.4가 됩니다. 조금 더 흥미로운(그리고 복잡한) 쪽은 전환에 대한 ATE입니다. 전환은 0과 1 사이로 제한되기 때문에 전환에 대한 ATE는 선형이 아닙니다. 그래서 잠재 결과처럼 간단한 공식으로 이끌어 낼 수 없습니다(공식이 있긴 하지만 꽤 복잡합니다). 일단은 효과가 더 작다고만 해 둡시다. 당연한 일 아닐까요? 전환은 100%를 넘을 수 없으니 처치가 전환을 1.4포인트나 올릴 방법은 없습니다. 이 사실을 꼭 기억해 두세요. 이어서 볼 내용을 이해하는 데 결정적인 역할을 합니다.

이제 조건부 평균 처치효과(CATE) 이야기를 해 보겠습니다. 데이터 생성 과정을 보면 estimated_income은 전환을 예측하지만 넛지가 전환에 미치는 효과를 바꾸지는 않는다는 사실을 확실히 알고 있습니다. 따라서 estimated_income을 기준으로 고객을 나누면 처치효과가 같은 세그먼트들이 만들어질 것입니다. 반면 age는 넛지와의 상호작용을 통해서만 전환에 영향을 줍니다. 그러므로 나이 세그먼트마다 처치에 대한 반응이 크게 다르겠지만, 소득 세그먼트끼리는 그렇지 않을 것입니다. 즉, estimated_income은 좋은 개인화 변수가 아니어야 하고 age는 좋은 개인화 변수여야 합니다.

이는 누적 효과 곡선(cumulative effect curve)으로 확인할 수 있습니다. age의 곡선은 ATE에서 아주 멀리 떨어진 곳에서 시작해 서서히 ATE로 수렴해야 하고, estimated_income의 곡선은 ATE 주변에서 오르내리기만 해야 합니다. 넛지가 잠재 결과에 미치는 효과로 누적 효과 곡선을 그려 보면 정확히 이 모습이 나타납니다.

코드
cumulative_effect_fn = cumulative_effect_curve(df, "nudge", "latent_outcome", min_rows=500)

age_cumm_effect_latent = cumulative_effect_fn(prediction="age")
inc_cumm_effect_latent = cumulative_effect_fn(prediction="estimated_income")

plt.plot(age_cumm_effect_latent, label="age")
plt.plot(inc_cumm_effect_latent, label="est. income")
plt.legend()
plt.xlabel("Percentile")
plt.ylabel("Effect on Latet Outcome");

역시 잠재 결과는 아주 깔끔합니다. 선형이기 때문에 예상과 실제가 꽤 잘 맞아떨어집니다. 하지만 현실에서는 잠재 결과에 관심도 없고, 관측할 수도 없습니다. 가진 것은 전환뿐입니다. 그리고 전환으로 보면 그림이 훨씬 복잡해집니다. 누적 효과 곡선을 그려 보면 age는 여전히 어느 정도 처치효과 이질성을 보입니다. ATE보다 위에서 시작해 서서히 ATE로 수렴합니다. 나이가 많을수록 처치효과가 크다는 뜻입니다. 여기까지는 괜찮습니다. 예상한 그대로입니다.

코드
cumulative_effect_fn = cumulative_effect_curve(df, "nudge", "conversion", min_rows=500)

age_cumm_effect_latent = cumulative_effect_fn(prediction="age")
inc_cumm_effect_latent = cumulative_effect_fn(prediction="estimated_income")

plt.plot(age_cumm_effect_latent, label="age")
plt.plot(inc_cumm_effect_latent, label="est. income")
plt.legend()
plt.xlabel("Percentile")
plt.ylabel("Effect on Conversion");

그런데 estimated_income에 따른 처치효과 이질성도 엄청나게 큽니다. estimated_income이 높은 고객은 처치효과가 훨씬 낮습니다. 그 때문에 누적 효과 곡선이 0에서 출발해 ATE를 훌쩍 넘어섰다가 그제야 ATE로 수렴합니다. 이는 개인화 관점에서 estimated_income으로 나눈 세그먼트가 age로 나눈 세그먼트보다 처치효과 이질성(TEH)이 더 크다는 뜻입니다.

곤란하지 않나요? 효과 이질성을 만든다고 알고 있는 특성인 age가, 처치효과를 바꾸지 않는다고 알고 있는 특성(estimated_income)보다 개인화에는 오히려 더 못하다니 어떻게 된 일일까요? 답은 결과 함수의 비선형성에 있습니다. estimated_income은 넛지가 잠재 결과에 미치는 효과를 바꾸지 않지만, 잠재 결과를 전환으로 변환하고 나면 (적어도 간접적으로는) 효과를 바꿉니다. 전환은 선형이 아닙니다. 즉, 곡선 위 어디에 있느냐에 따라 도함수가 달라집니다. 전환은 최대 1까지만 올라갈 수 있으므로, 이미 아주 높다면 더 끌어올리기 어렵습니다. 다시 말해 전환이 높은 곳에서는 도함수가 매우 작습니다. 반대쪽 끝도 마찬가지입니다. 전환은 아래로도 0에서 막혀 있으므로, 이미 아주 낮을 때도 도함수가 작습니다. 전환은 양 끝에서 도함수가 작은 S자 모양을 따릅니다. estimated_income 구간(폭 100)별 평균 전환을 그려 보면 이 모양을 확인할 수 있습니다.

코드
(df
 .assign(estimated_income_bins=(df["estimated_income"]/100).astype(int)*100)
 .groupby("estimated_income_bins")
 [["conversion"]]
 .mean()
 .plot()
);

전환이 아주 높을 때 이 곡선의 기울기(도함수)가 무척 작다는 점에 주목하세요. 전환이 아주 낮을 때도 기울기가 작습니다(다만 그 구간은 표본이 적어서 알아보기가 더 어렵습니다). 이 사실로 estimated_income이 처치효과 이질성이 큰 세그먼트를 만드는 이유를 설명할 수 있습니다.

estimated_income은 전환을 아주 잘 예측하므로, estimated_income이 다른 고객은 S자 전환 곡선 위에서 서로 다른 위치에 있다고 볼 수 있습니다. estimated_income이 아주 높거나 아주 낮은 고객은 곡선의 양 끝에 있습니다. 그곳은 도함수가 작아서 전환을 끌어올리기 어렵고, 결국 처치효과도 작을 가능성이 큽니다. 반면 소득이 중간 범위에 있는 고객은 전환 곡선에서도 가운데에 놓입니다. 그곳은 도함수가 커서 처치효과도 클 가능성이 큽니다. ’가능성이 크다’고 말한 이유는, 이론적으로는 어떤 변수가 처치효과를 바꾸는 힘이 너무 강해서 전환 곡선을 따라 움직일 때 생기는 도함수 변화를 압도할 수도 있기 때문입니다. 하지만 적어도 제 경험으로는 S자 전환 곡선의 곡률이 다른 모든 효과 수정(effect modification)을 압도하는 경향이 있습니다.

저만 이렇게 생각하는 것은 아닙니다. 다음은 Susan Athey가 Columbia Data Science Institute에서 발표한 자료의 슬라이드입니다. 여기서 Athey는 학생들이 대학 학비를 마련하려고 연방 학자금 지원을 신청하도록 유도하는 넛지의 효과를 다룹니다. 이것도 전환 문제입니다. Athey가 발견한 사실은 이미 전환할 가능성이 높은 학생을 겨냥하는 것이 최선의 전략이라는 것입니다. 또 전환 확률이 낮은 학생을 겨냥하는 것은 좋지 않은 생각이라고 말합니다.

image.png

잠깐만요! 아까 한 말과 다르잖아요! 가운데에서 멀어져 아주 낮거나 아주 높은 쪽으로 갈수록 처치효과가 작아진다고 했잖아요!

맞습니다. 하지만 현실에서 전환이 S자 곡선 전체에 걸쳐 퍼져 있는 경우는 드뭅니다. 대개는 모두가 곡선의 한쪽 끝에 몰려 있습니다. 비즈니스 용어로 말하면 평균 전환율이 50%인 경우는 드뭅니다. 70~90% 정도이거나 1~20% 정도인 경우가 훨씬 많습니다. 이처럼 더 흔한 상황에서는 기준 전환율이 높은 고객을 겨냥하는 것이 좋은 생각일 수도, 나쁜 생각일 수도 있습니다.

무슨 뜻인지 보여 드리겠습니다. 앞에서 만든 잠재 결과를 그대로 쓰되, 이번에는 latent_outcome > 2로 정해 평균 전환율이 낮은 상황을 만듭니다. 그다음 latent_outcome > -2로 정해 전환율이 높은 상황도 만들어 봅시다.

df["conversion_low"] = conversion = (latent_outcome > 2).astype(int)
df["conversion_high"] = conversion = (latent_outcome > -2).astype(int)

print("Avg. Low Conversion: ", df["conversion_low"].mean())
print("Avg. High Conversion: ", df["conversion_high"].mean())
Avg. Low Conversion:  0.12119
Avg. High Conversion:  0.9275

전환이 비선형이라는 사실을 바탕으로 무슨 일이 일어날지 미리 예측할 수 있습니다. 전환율이 낮은 상황에서는 기준 전환율이 높은 고객(estimated_income이 높은 고객)을 겨냥하는 편이 훨씬 효과적일 것입니다. S자 전환 곡선의 왼쪽에 있기 때문입니다. 이 구간에서는 기준 전환율이 낮을수록 도함수가 작습니다. 즉, 이 구간에서는 기준 전환율이 높으면 처치효과도 큽니다. 따라서 기준 전환율이 높은 고객, 곧 estimated_income이 높은 고객에게 넛지를 주어야 합니다.

코드
cumulative_effect_fn = cumulative_effect_curve(df, "nudge", "conversion_low", min_rows=500)

age_cumm_effect_latent = cumulative_effect_fn(prediction="age")
inc_cumm_effect_latent = cumulative_effect_fn(prediction="estimated_income")

plt.plot(age_cumm_effect_latent, label="age")
plt.plot(inc_cumm_effect_latent, label="est. income")
plt.xlabel("Percentile")
plt.ylabel("Effect on Conversion");
plt.legend();

예측한 대로 estimated_income이 높은 고객, 즉 기준 전환율이 높은 고객의 처치효과가 훨씬 큽니다.

이제 평균 전환율이 높은 반대 상황에서는 기준 전환율이 높은 고객의 처치효과가 더 작을 것입니다. 그러므로 estimated_income이 높은 고객을 겨냥하는 것은 나쁜 생각입니다. 누적 효과 곡선이 뒤집힌 모양으로 나타나는 것을 보면 알 수 있습니다. 이 곡선은 estimated_income이 높은 고객일수록 처치효과가 작다는 것을 보여 줍니다.

코드
cumulative_effect_fn = cumulative_effect_curve(df, "nudge", "conversion_high", min_rows=500)

age_cumm_effect_latent = cumulative_effect_fn(prediction="age")
inc_cumm_effect_latent = cumulative_effect_fn(prediction="estimated_income")

plt.plot(age_cumm_effect_latent, label="age")
plt.plot(inc_cumm_effect_latent, label="est. income")
plt.xlabel("Percentile")
plt.ylabel("Effect on Conversion")
plt.legend();

정리하면, 결과가 이진일 때 처치효과는 S자 함수의 곡률(도함수)에 좌우되는 경향이 있습니다.

image.png

예를 들어 앞의 전환 문제에서 평균 전환율이 낮으면 로지스틱 곡선의 왼쪽에 있게 되고, 기준 전환율이 높은 곳에서 처치효과가 더 큽니다. 이는 이미 전환 확률이 높은 고객에게 처치하자는(넛지를 주자는) 정책으로 이어집니다. 반대로 평균 전환율이 높으면 로지스틱 곡선의 오른쪽에 있게 되고, 그곳에서는 도함수(따라서 처치효과)가 기준 전환율이 낮은 고객에게서 더 큽니다.

기억할 것이 많아 보이지만 간단하게 줄일 수 있습니다. 그냥 기준 전환율이 50%에 더 가까운 대상을 처치하세요. 수학적 근거도 탄탄합니다. 로지스틱 함수의 도함수는 50%에서 가장 크니, 그 지점 가까이에 있는 대상을 처치하면 됩니다.

더 반가운 점은 이것이 통념과 수학이 맞아떨어지는 보기 드문 사례라는 것입니다. 전환 문제가 아주 흔한 마케팅 분야에는 가망 없는 고객(전환 확률이 아주 낮은 고객)도, 따 놓은 당상인 고객(전환 확률이 아주 높은 고객)도 겨냥하지 말고 그 중간에 있는 고객을 겨냥해야 한다는 믿음이 있습니다. 좀 더 엄밀한 인과적 논증으로 알아낸 내용과 정확히 같다는 점이 무척 흥미롭습니다.

24 연속형 처치와 비선형성

지금까지 이진 결과가 이질적 처치효과(heterogeneous treatment effect) 분석을 어렵게 만드는 예 하나를 깊이 살펴보았습니다. 하지만 이 현상은 마케팅의 전환 문제에만 국한되지 않습니다. 예를 들어 2021년, 세계는 처음으로 승인된 코로나19 백신을 일반 대중에게 공급했습니다. 당시 핵심 질문은 누가 먼저 백신을 맞아야 하느냐였습니다. 놀랄 것도 없이 이는 이질적 처치효과 문제입니다. 정책 입안자들은 가장 큰 혜택을 볼 사람들에게 먼저 백신을 접종하고 싶어 했습니다. 이 상황에서 처치효과는 사망이나 입원을 막는 것입니다. 그렇다면 백신을 맞았을 때 사망이나 입원 가능성이 가장 많이 줄어드는 사람은 누구일까요? 대부분의 나라에서는 노인과 기저질환(동반 질환)이 있는 사람들이었습니다. 이들은 코로나19에 감염되면 사망할 가능성이 더 높은 사람들입니다. 또한 코로나19 치명률은 (다행히도!) 50%보다 훨씬 낮으므로 로지스틱 함수의 왼쪽에 해당합니다. 이 구간에서는 마케팅 사례와 같은 논리로, 코로나19에 걸렸을 때 기준 사망 확률이 높은 사람들에게 처치하는 것이 합리적입니다. 바로 앞에서 말한 그룹들입니다. 이것이 우연일까요? 그럴지도 모릅니다. 저는 보건 전문가가 아니니 완전히 틀렸을 수도 있다는 점은 감안해 주세요. 하지만 제가 보기에는 논리가 아주 그럴듯합니다.

마케팅 넛지와 코로나19 백신 두 사례 모두에서 처치효과 이질성을 복잡하게 만드는 핵심 요인은 결과 함수 \(Y(0)\)의 비선형성입니다. 이 비선형성 때문에 \(Y(0)\)에서 \(Y(1)\)로 갈 때 결과가 늘어나는 정도가 주로 결과 함수의 곡률에 따라 정해집니다. 앞에서는 \(E[Y|X]\)가 로지스틱 모양을 따르는 이진 결과에서 이런 일이 어떻게 일어나는지 보았습니다. 하지만 이 현상은 더 일반적입니다. 실제로 비즈니스에서 계속 불거지는 문제이고, 처치가 연속형 변수라면 특히 그렇습니다. 요점을 확실히 짚기 위해 마지막 예를 하나 더 살펴보겠습니다.

전형적인 가격 책정 문제를 생각해 봅시다. 넷플릭스나 HBO 같은 스트리밍 회사에서 일한다고 해 보겠습니다. 회사가 답을 얻고 싶은 핵심 질문은 고객에게 가격을 얼마로 매길지입니다. 이 질문에 답하려고 회사는 고객을 서로 다른 가격의 요금제(월 5헤알, 월 10헤알, 월 15헤알, 월 20헤알)에 무작위로 배정하는 실험을 합니다. 이렇게 해서 고객이 가격 인상에 얼마나 민감한지뿐 아니라, 어떤 유형의 고객이 다른 고객보다 더 민감한지도 알아내려 합니다. 아래 그래프는 이 실험 결과를 두 고객 세그먼트로 나누어 보여 줍니다. A는 추정 소득이 높은 고객이고, B는 추정 소득이 낮은 고객입니다.

코드
data = pd.DataFrame(dict(
    segment= ["b", "b", "b", "b",  "a", "a", "a", "a",],
    price=[5, 10, 15, 20, ] * 2,
    sales=[5100, 5000, 4500, 3000,  5350, 5300, 5000, 4500]
))

plt.figure(figsize=(8,4))
sns.lineplot(data=data, x="price", y="sales", hue="segment")
plt.title("Avg. Sales by Price (%) by Customer Segment");

회사는 이 데이터로 할인에 누가 더 민감한지 알고 싶어 합니다. 다시 말해 가격 민감도(판매량의 가격 탄력성)에 따라 고객의 순위를 어떻게 매길 수 있을지 알고 싶습니다. 곡선을 보면 세그먼트 A가 매출은 더 많이 내지만 할인에는 전반적으로 덜 민감하다는 느낌이 듭니다. 하지만 곡선이 어느 정도 휘어 있다는 것도 볼 수 있습니다. 실제로 이 곡률을 고려하면 처치효과의 순위는 더 이상 A 고객과 B 고객 사이에서만 정해지지 않습니다. 처치효과는 고객이 처치 곡선 위 어디에 있는지에도 달려 있습니다. 예를 들어 세그먼트 A 고객에게 가격을 15헤알에서 10헤알로 바꿀 때의 처치효과는 세그먼트 B 고객에게 가격을 5헤알에서 10헤알로 바꿀 때의 처치효과보다 큽니다.

\[ E[Y(10) - Y(5) | Seg=B] < E[Y(15) - Y(10) | Seg=A] \]

이 실험에서 얻은 처치효과를 순서대로 늘어놓으면 대략 다음과 같습니다.

코드
plt.figure(figsize=(8,4))
sns.lineplot(data=data, x="price", y="sales", hue="segment")

plt.annotate("1", (8, 5350), bbox=dict(boxstyle="round", fc="1"))
plt.annotate("2", (8, 5000), bbox=dict(boxstyle="round", fc="1"))
plt.annotate("3", (13, 5100), bbox=dict(boxstyle="round", fc="1"))
plt.annotate("4", (13, 4700), bbox=dict(boxstyle="round", fc="1"))
plt.annotate("4", (17, 4800), bbox=dict(boxstyle="round", fc="1"))
plt.annotate("5", (17, 3900), bbox=dict(boxstyle="round", fc="1"))

plt.title("Ordering of the Effect of Increasing Price");

결과가 이진이었던 경우와 마찬가지로 이 예에서도 처치효과와 결과 사이에 상관관계가 있습니다. 판매량이 많을수록(가격이 낮을수록) 처치효과의 절댓값이 작고, 판매량이 적을수록(가격이 높을수록) 처치효과의 절댓값이 큽니다. 하지만 이번에는 효과가 결과뿐 아니라 처치 수준과도 상관관계가 있기 때문에 상황이 더 복잡합니다. 그래서 반사실적 질문에 답하기가 더 까다롭습니다. 예를 들어 실제 실험 데이터가 다음 그래프와 같다고 잠시 상상해 봅시다. 세그먼트 A(부유한 집단)에는 높은 가격을 시험하고 B 집단에는 낮은 가격만 시험한 경우입니다. 기업은 자신들이 더 합리적이라고 생각하는 처치 수준 주변에서 실험하고 싶어 하는 경우가 많으므로 이런 상황은 아주 흔합니다.

힌트옮긴이

원문은 ’판매량이 적을수록(가격이 높을수록) 처치효과의 절댓값이 작다’로 되어 있지만, 그래프와 앞뒤 맥락상 ’절댓값이 크다’의 오기로 보여 바로잡았습니다. 낮은 가격만 시험한 집단도 원문에는 A로 되어 있으나 그래프와 다음 셀의 설명상 B를 가리키므로 바로잡았습니다.

코드
data = pd.DataFrame(dict(
    segment= ["b", "b", "b", "b",  "a", "a", "a", "a",],
    price=[5, 10, 15, 20, ] * 2,
    sales=[5100, 5000, 4500, 3000,  5350, 5300, 5000, 4500]
))

plt.figure(figsize=(8,4))
sns.lineplot(data=data.loc[lambda d: (d["segment"] == "a") | (d["price"] < 12) ], x="price", y="sales", hue="segment")
plt.title("Avg. Sales by Price (%) by Customer Segment");

이제 처치효과 결과를 별생각 없이 합쳐 보면, 세그먼트 A가 세그먼트 B보다 가격 인상에 훨씬 더 탄력적인(처치효과의 절댓값이 더 큰) 것처럼 보일 것입니다. 하지만 이는 세그먼트 B에서는 처치효과가 작은 구간만 살펴보았기 때문일 뿐입니다.

그렇다면 처치와 결과 양쪽에서 어디에 있느냐에 따라 처치효과가 달라질 때는 어떻게 해야 할까요? 솔직히 말하면 아직 활발히 연구 중인 분야입니다. 실무에서 할 수 있는 최선은 어떤 유형의 고객이 처치에 더 민감한지 답하려 할 때 아주 조심하는 것입니다. 비교하는 고객 유형들이 모두 같은 처치 분포를 받았는지 확인하세요. 그렇지 않다면 외삽한 처치효과는 크게 의심해야 합니다. 예를 들어 위의 예에서 B 고객은 가격 인상에 덜 민감해 보이지만, 이 세그먼트에 10헤알을 넘는 더 높은 가격을 매겨도 여전히 그럴지는 알 수 없습니다.

반응 곡선을 선형화해 볼 수도 있습니다. 처치나 결과(또는 둘 다)를 변환해 둘의 관계가 직선처럼 보이게 만들어 곡률을 없애자는 아이디어입니다. 직선은 도함수가 일정하므로, 곡선 위 어디에 있느냐에 따라 처치효과가 달라지는 문제가 사라집니다. 예를 들어 가격 변수를 음수로 바꾸고 4제곱한 뒤 다시 부호를 뒤집으면 어느 정도 선형에 가까운 관계를 얻을 수 있습니다. 이렇게 변환한 데이터에서는 A가 B보다 가격 인상에 덜 민감하다는 말이 훨씬 설득력을 얻습니다. 이제 곡선 위 어디에 있느냐에 좌우되지 않기 때문입니다.


plt.figure(figsize=(8,4))
sns.lineplot(data=data.assign(price = lambda d: -1*(-d["price"]**4)),
             x="price", y="sales", hue="segment")
plt.title("Avg. Sales by -(-price^4)");

하지만 이 방법에는 단점이 많습니다. 첫째, 곡선을 늘 선형화할 수 있는 것은 아닙니다. 이 예에서도 선형화가 완벽하지 않다는 것이 분명히 보입니다. 그보다 더 중요한 점은, 곡률을 버리는 것이 비즈니스 측면에서 말이 안 될 때가 있다는 것입니다. 가격 책정 예에서는 가격 15의 A 고객을 가격 5의 B 고객보다 더 민감하다고 보아도 전혀 문제가 없을 수 있습니다. 그렇게 보면 A 고객의 가격은 15에서 10으로 내리고 B 고객의 가격은 그대로 두는 타당한 결정을 내릴 수 있습니다.

핵심 요약

답을 드리기보다 질문을 더 많이 던진 것 같습니다. 아쉽지만 어떤 문제는 그런 문제가 있다는 사실을 똑똑히 알아 두는 것이 할 수 있는 최선일 때도 있습니다. 이 장에서 관심 있는 결과가 비선형일 때 생기는 복잡한 문제들에 눈뜰 수 있었기를 바랍니다.

이진 결과에서는 흔하고 연구도 더 많이 된 문제입니다. 이 경우 평균 결과가 0.5에 가까울수록 처치효과가 커지는 경향이 있습니다. 결과가 0과 1 사이로 제한되므로 0이나 1에 너무 가까우면 효과가 아주 작아지는 경향이 있습니다.

연속형 결과에서 비선형성이 생기면 상황이 더 복잡해집니다. 이때 할 수 있는 최선은 문제를 아주 신중하게 생각하는 것입니다. 처치의 기준 수준과 상관없이 처치효과 자체에 더 관심이 있는지, 아니면 기준 수준이 중요한지 스스로 답해 보세요. 그것만으로도 귀중한 길잡이가 될 것입니다.

참고 자료

여기에 쓴 내용은 대부분 이 문제를 직접 겪은 제 경험에서 나왔습니다. 다만 이 주제를 다룬 학술 논문을 하나 찾았습니다. Fernández-Loría와 Provost의 Causal Classification: Treatment Effect Estimation vs. Outcome Prediction은 처치효과가 결과변수와 상관관계를 가지는 경우를 다룹니다.

기여하기

Causal Inference for the Brave and True는 과학의 통계학이라 할 수 있는 인과추론을 다루는 오픈소스 자료입니다. 파이썬 기반의 무료 소프트웨어만 사용하며, 돈이 없어도, 배경지식이 많지 않아도 누구나 볼 수 있는 자료를 목표로 합니다. 이 책이 도움이 되었고 후원하고 싶다면 Patreon을 방문해 주세요. 금전적으로 기여하기 어렵다면 오타를 고치거나, 수정할 부분을 제안하거나, 이해하기 어려웠던 부분에 의견을 주는 것으로도 도울 수 있습니다. 책의 저장소에서 이슈를 열어 주세요. 마지막으로 이 내용이 마음에 들었다면 도움이 될 만한 사람들과 공유하고 GitHub에서 스타를 눌러 주세요.

힌트옮긴이

한국어판의 오역이나 오탈자는 한국어판 이슈 페이지에 알려 주세요. 한국어판이 도움이 되었다면 한국어판 저장소에도 스타를 부탁드립니다.

의견 나누기

번역이 어색한 곳, 더 쉬운 설명, 궁금한 점을 남겨 주세요. GitHub 계정으로 로그인하면 댓글을 달 수 있고, 남긴 의견은 GitHub Discussions에 장별로 모입니다.