인과관계를 다루는 자료 대부분에서 연구자들은 자기 방법이 쓸 만한지 확인하려고 합성 데이터를 사용합니다. 부록의 ‘When Prediction Fails’ 장에서 했던 것처럼 \(Y_{0i}\)와 \(Y_{1i}\)를 모두 생성해 두고, 모델이 처치효과(treatment effect) \(Y_{1i} - Y_{0i}\)를 제대로 잡아내는지 확인하는 식입니다. 학술적인 목적이라면 이걸로 충분하지만, 현실에서는 그런 호사를 누릴 수 없습니다. 이런 기법을 업계에 적용하다 보면 우리 모델이 왜 더 나은지, 왜 지금 운영 중인 모델을 대체해야 하는지, 왜 처참하게 실패하지 않을 거라고 장담할 수 있는지 증명하라는 요구를 몇 번이고 받게 됩니다. 이토록 중요한 문제인데도 실제 데이터로 인과추론 모델을 어떻게 평가해야 하는지 설명하는 자료를 도무지 찾아볼 수 없다는 건 제 상식으로는 이해가 되지 않습니다.
그 결과, 인과추론 모델을 적용하려는 데이터 과학자들은 경영진이 모델을 믿게 만드는 데 무척 애를 먹습니다. 이들이 주로 택하는 방법은 이론이 얼마나 탄탄한지, 모델을 학습할 때 얼마나 신중했는지 보여 주는 것입니다. 안타깝게도 훈련-테스트 분할 패러다임이 표준인 세상에서는 그것만으로 부족합니다. 모델의 품질은 아름다운 이론보다 더 구체적인 무언가에 근거해야 합니다. 생각해 보세요. 머신러닝이 이렇게 크게 성공할 수 있었던 것도 예측 모델을 검증하기가 아주 간단했기 때문입니다. 예측이 실제로 일어난 일과 맞아떨어지는 걸 눈으로 확인하면 어쩐지 안심이 됩니다.
그런데 인과추론에서 훈련-테스트 패러다임 같은 것을 어떻게 구현할 수 있을지는 전혀 분명하지 않습니다. 인과추론은 관측할 수 없는 양인 \(\frac{\delta y}{ \delta t}\)를 추정하는 데 관심을 두기 때문입니다. 아니, 볼 수도 없는 걸 모델이 잘 추정하는지 대체 어떻게 안단 말입니까? 기억하세요. 모든 개체에는 저마다 고유한 반응도, 즉 처치에서 결과로 이어지는 직선의 기울기가 있는 셈이지만 그 기울기는 측정할 수 없습니다.
정말, 정말, 정말 머리로 받아들이기 어려운 문제라서, 답에 가까운 무언가를 찾기까지 저도 몇 년이 걸렸습니다. 결정적인 답은 아니지만 실무에서 잘 통하고 구체적입니다. 이 방법으로 머신러닝의 훈련-테스트 패러다임과 비슷하게 인과추론에 다가갈 수 있기를 기대합니다. 요령은 민감도(sensitivity)를 집계 수준에서 측정하는 것입니다. 민감도를 대상마다 따로 추정할 수는 없어도 그룹 단위로는 추정할 수 있고, 이 장에서는 바로 그 점을 활용합니다.
힌트옮긴이
그림의 ’SNEAK 100’은 게임 『엘더스크롤 5: 스카이림』에서 은신 기술을 최고 단계까지 올렸다는 표시입니다. 각 점의 기울기가 그만큼 감쪽같이 숨어 있어서 관측할 수 없다는 농담입니다.
코드
import pandas as pdimport numpy as npfrom matplotlib import pyplot as pltimport seaborn as snsfrom toolz import curryimport statsmodels.formula.api as smfimport statsmodels.api as smfrom sklearn.ensemble import GradientBoostingRegressorimport warningswarnings.filterwarnings("ignore")
이 장에서는 비무작위 데이터로 인과 모델을 추정하고, 무작위 데이터로 그 모델을 평가합니다. 이번에도 가격이 아이스크림 판매량에 미치는 영향을 다룹니다. 곧 보겠지만 무작위 데이터는 평가에 아주 귀중합니다. 하지만 현실에서는 무작위 데이터를 모으는 데 비용이 많이 드는 경우가 많습니다(어떤 가격은 별로라서 돈만 날릴 걸 뻔히 아는데 왜 가격을 무작위로 정하겠습니까???). 그래서 처치가 무작위가 아닌 데이터는 넘쳐나는데 무작위 데이터는 아주 적거나 아예 없는 경우가 흔합니다. 비무작위 데이터로 모델을 평가하기는 엄청나게 까다롭기 때문에, 무작위 데이터가 조금이라도 있다면 평가용으로 남겨 두는 편입니다.
X = ["temp", "weekday", "cost", "price"]y ="sales"np.random.seed(1)m2 = GradientBoostingRegressor()m2.fit(prices[X], prices[y]);
모델이 심하게 과적합(overfitting)되지 않았는지 확인하려면, 학습에 쓴 데이터와 처음 보는 새 데이터에서 각각 \(R^2\)를 확인해 보면 됩니다(머신러닝에 익숙한 분이라면, 여기에는 개념 드리프트(concept drift)가 있으므로 성능이 어느 정도 떨어지는 게 당연하다는 점을 알아 두세요. 모델은 가격이 무작위가 아닌 데이터로 학습했지만, 테스트 셋에는 무작위로 정한 가격만 들어 있습니다).
두 모델은 비무작위 데이터로 학습했습니다. 이제 무작위 데이터로 넘어가 예측을 합니다. 모든 결과를 한곳에 모아 두기 위해, 머신러닝 모델의 예측값과 인과 모델의 민감도 예측값을 prices_rnd_pred라는 데이터프레임 하나에 담겠습니다.
여기에 무작위 모델도 하나 넣겠습니다. 이 모델은 그냥 무작위 숫자를 예측값으로 내놓습니다. 당연히 별 쓸모는 없지만 벤치마크로는 제 몫을 톡톡히 합니다. 새로운 평가 방법을 이야기할 때면 저는 늘 무작위(쓸모없는) 모델이 그 방법으로 어떤 점수를 받을지 생각해 봅니다. 무작위 모델이 그 평가 기준에서 좋은 성적을 낸다면, 그 평가 방법이 실제로 얼마나 좋은지 알려 주는 셈이기 때문입니다.
예측값을 구했으니 이제 그 예측이 얼마나 좋은지 평가해야 합니다. 그런데 민감도는 관측할 수 없으니 비교할 간단한 참값(ground truth)이 없다는 점을 기억하세요. 대신 민감도 모델에 무엇을 바라는지 다시 생각해 봅시다. 그러면 모델을 어떻게 평가해야 할지 실마리를 얻을 수 있을지도 모릅니다.
처치 민감도(treatment sensitivity) 모델을 만들자는 생각은 어떤 대상이 처치에 더 민감하고 어떤 대상이 덜 민감한지 찾아야 할 필요에서 나왔습니다. 개인화(personalisation)하고 싶다는 바람이 그 출발점입니다. 어떤 마케팅 캠페인은 인구 중 한 세그먼트에서만 효과가 클 수도 있습니다. 할인이 특정 유형의 고객에게만 먹힐 수도 있습니다. 좋은 인과 모델이라면 제안한 처치에 어떤 고객이 더 잘 반응하고 어떤 고객이 덜 반응할지 찾는 데 도움이 되어야 합니다. 처치에 얼마나 탄력적인지, 즉 얼마나 민감한지에 따라 대상을 나눌 수 있어야 합니다. 아이스크림 예제라면 사람들이 아이스크림에 돈을 더 쓸 의향이 있는 날, 다시 말해 가격 민감도가 덜 음수인 날을 모델이 찾아낼 수 있어야 합니다.
목표가 그렇다면 대상을 민감도가 높은 순서대로 정렬할 수 있으면 아주 유용할 것입니다. 민감도 예측값이 있으니 그 값으로 대상을 정렬하고, 그 순서가 실제 민감도 순서와도 맞기를 기대할 수 있습니다. 안타깝게도 그 순서를 개별 대상 수준에서 평가할 수는 없습니다. 하지만 꼭 개별 수준에서 평가해야 할까요? 대신 그 순서로 정의한 그룹을 평가하면 어떨까요? 처치가 무작위로 배정되어 있다면(무작위성은 바로 여기서 등장합니다) 대상 그룹의 민감도를 추정하기는 쉽습니다. 실험군과 대조군의 결과를 비교하기만 하면 됩니다.
이를 더 잘 이해하려면 이진 처치를 떠올려 보는 게 좋습니다. 가격 예제를 그대로 쓰되, 이번에는 처치가 할인이라고 합시다. 다시 말해 가격은 높거나(대조군) 낮을(실험군) 수 있습니다. Y축에 판매량, X축에 각 모델의 예측값을 두고 가격을 색으로 표시해 보겠습니다. 그런 다음 모델 축을 기준으로 데이터를 같은 크기의 세 그룹으로 나눌 수 있습니다. 처치가 무작위로 배정되었다면 각 그룹의 평균 처치효과(ATE) \(E[Y|T=1] - E[Y|T=0]\)를 쉽게 추정할 수 있습니다.
그림을 보면 첫 번째 모델은 판매량을 꽤 잘 예측하지만(판매량과 상관관계가 높음), 아래쪽 그래프에서 보듯이 이 모델이 만든 그룹들의 처치효과는 거의 같습니다. 세 구간 중 두 구간은 민감도가 같고, 마지막 구간만 민감도가 달라서 더 낮습니다.
반면 두 번째 모델이 만든 그룹은 저마다 인과효과가 다릅니다. 이 모델이 개인화에 실제로 쓸모 있을 수 있다는 신호입니다. 마지막으로 무작위 모델이 만든 그룹은 민감도가 모두 똑같습니다. 그다지 유용하지는 않지만 예상한 결과입니다. 모델이 무작위라면 그 모델이 만든 각 구간은 데이터에서 무작위로 뽑은 대표 표본이 됩니다. 따라서 각 그룹의 민감도는 전체 데이터셋의 ATE와 거의 같아야 합니다.
이 그래프만 봐도 어느 모델이 더 나은지 감을 잡을 수 있습니다. 민감도가 질서 있게 늘어서 보일수록, 구간 사이의 차이가 클수록 좋습니다. 여기서는 모델 2가 아마 모델 1보다 낫고, 모델 1은 아마 무작위 모델보다 나을 것입니다.
여기서 \(\bar{t}\)는 처치의 표본평균이고 \(\bar{y}\)는 결과의 표본평균입니다. 코드로 쓰면 다음과 같습니다.
@currydef sensitivity(data, y, t):# line coeficient for the one variable linear regression return (np.sum((data[t] - data[t].mean())*(data[y] - data[y].mean())) / np.sum((data[t] - data[t].mean())**2))
이제 이 함수를 아이스크림 가격 데이터에 적용해 봅시다. 그러려면 데이터셋을 같은 크기의 구간으로 나누고 구간마다 민감도를 계산하는 함수도 필요합니다. 다음 코드가 그 일을 합니다.
def sensitivity_by_band(df, pred, y, t, bands=10):return (df .assign(**{f"{pred}_band":pd.qcut(df[pred], q=bands)}) # makes quantile partitions .groupby(f"{pred}_band") .apply(sensitivity(y=y, t=t))) # estimate the sensitivity on each partition
마지막으로 앞에서 만든 예측값으로 구간별 민감도를 그려 보겠습니다. 각 모델로 구간을 나눈 다음 구간마다 민감도를 추정합니다.
fig, axs = plt.subplots(1, 3, sharey=True, figsize=(10, 4))for m, ax inzip(["sensitivity_m_pred", "pred_m_pred", "rand_m_pred"], axs): sensitivity_by_band(prices_rnd_pred, m, "sales", "price").plot.bar(ax=ax)
먼저 무작위 모델(rand_m)을 봅시다. 모든 구간에서 추정된 민감도가 거의 같습니다. 그래프만 봐도 이 모델은 가격 민감도가 높은 날과 낮은 날을 구분하지 못하니 개인화에 별 도움이 안 되리라는 걸 알 수 있습니다. 다음은 예측 모델 pred_m입니다. 이 모델은 의외로 꽤 유망합니다! 민감도가 높은 그룹과 낮은 그룹을 만들어 내는데, 바로 지금 필요한 것입니다.
마지막으로 인과 모델 sensitivity_m은 조금 이상해 보입니다. 이 모델은 민감도가 아주 낮은 그룹을 찾아내는데, 여기서 낮다는 건 사실 가격 민감도가 높다는 뜻입니다(가격을 올리면 판매량이 크게 줄어듭니다). 가격 민감도가 높은 날을 찾아내는 건 아주 유용합니다. 언제가 그런 날인지 알면 그날만큼은 가격을 계속 올리지 않도록 조심할 수 있으니까요. 인과 모델은 덜 민감한 구간도 일부 찾아내므로 민감도가 높은 경우와 낮은 경우를 제대로 구분합니다. 하지만 정렬 순서는 예측 모델만큼 좋지 않습니다.
그렇다면 어떻게 결정해야 할까요? 예측 모델과 인과 모델 중 어느 쪽이 더 쓸모 있을까요? 예측 모델은 정렬이 더 낫지만, 인과 모델은 극단을 더 잘 찾아냅니다. 구간별 민감도 그래프는 첫 점검용으로는 좋지만, 어느 모델이 더 나은지 정확히 답해 주지는 못합니다. 좀 더 정교한 방법이 필요합니다.
누적 민감도 곡선
가격을 이진 처치로 바꾼 예시로 다시 돌아가 보겠습니다. 앞에서 구간별 처치 민감도를 구한 데서 이어 갑니다. 다음으로 할 일은 구간을 민감도 순으로 정렬하는 것입니다. 가장 민감한 그룹을 첫 번째에, 두 번째로 민감한 그룹을 두 번째에 놓는 식입니다. 모델 1과 3은 이미 정렬되어 있으니 순서를 바꿀 필요가 없습니다. 모델 2는 순서를 뒤집어야 합니다.
그룹을 정렬하고 나면 누적 민감도 곡선(cumulative sensitivity curve)이라고 부를 곡선을 만들 수 있습니다. 먼저 첫 번째 그룹의 민감도를 계산하고, 그다음 첫 번째와 두 번째 그룹을 합쳐 계산하는 식으로 모든 그룹이 들어갈 때까지 반복합니다. 마지막에는 결국 전체 데이터셋의 민감도를 계산하게 됩니다. 예시에서는 다음과 같은 모습이 됩니다.
누적 민감도의 첫 번째 구간은 그 모델 기준으로 가장 민감한 그룹의 ATE일 뿐이라는 점에 주목하세요. 또한 어느 모델이든 누적 민감도는 같은 지점, 즉 전체 데이터셋의 ATE로 수렴합니다.
이 수열은 모델 평가 측면에서 아주 흥미롭습니다. 이 수열을 두고 모델의 우열을 가리는 기준을 세울 수 있기 때문입니다. 첫째, 다음 조건을 잘 만족할수록 더 좋은 모델입니다.
\(\hat{y}'(t)_k > \hat{y}'(t)_{k+a}\)
이 조건은 임의의 \(k\)와 \(a>0\)에 대한 것입니다. 말로 풀면, 모델이 민감도를 잘 정렬한다면 상위 \(k\)개 표본에서 관측한 민감도가 상위 \(k+a\)개 표본에서 관측한 민감도보다 높아야 합니다. 더 간단히 말해, 위쪽에 있는 대상들은 그 아래 대상들보다 민감도가 높아야 합니다.
둘째, 다음 값이 클수록 더 좋은 모델입니다.
\(\hat{y}'(t)_k - \hat{y}'(t)_{k+a}\)
이 역시 임의의 \(k\)와 \(a>0\)에 대한 것입니다. 상위 \(k\)개 대상의 민감도가 그 아래 대상들보다 높기만 해서는 부족하고, 그 차이가 가능한 한 커야 한다는 직관입니다.
좀 더 구체적으로, 이 아이디어를 코드로 옮기면 다음과 같습니다.
def cumulative_sensitivity_curve(dataset, prediction, y, t, min_periods=30, steps=100): size = dataset.shape[0]# orders the dataset by the `prediction` column ordered_df = dataset.sort_values(prediction, ascending=False).reset_index(drop=True)# create a sequence of row numbers that will define our Ks# The last item is the sequence is all the rows (the size of the dataset) n_rows =list(range(min_periods, size, size // steps)) + [size]# cumulative computes the sensitivity. First for the top min_periods units.# then for the top (min_periods + step*1), then (min_periods + step*2) and so onreturn np.array([sensitivity(ordered_df.head(rows), y, t) for rows in n_rows])
이 함수에서 몇 가지 짚고 넘어가겠습니다. 이 함수는 민감도 순서를 정하는 값이 prediction 인수로 넘긴 열에 들어 있다고 가정합니다. 또 첫 번째 그룹에는 대상이 min_periods개 들어가므로 다른 그룹과 크기가 다를 수 있습니다. 곡선 초반에는 표본 크기가 작아서 민감도에 잡음이 너무 많이 낄 수 있기 때문입니다. 이를 해결하려고 처음부터 충분히 큰 첫 그룹을 넘겨 주는 것입니다. 마지막으로 steps 인수는 이후 그룹마다 대상을 몇 개씩 더 넣을지 정합니다.
이 함수로 이제 각 모델이 만든 순서에 따라 누적 민감도 곡선을 그릴 수 있습니다.
코드
plt.figure(figsize=(10,6))for m in ["sensitivity_m_pred", "pred_m_pred", "rand_m_pred"]: cumu_sens = cumulative_sensitivity_curve(prices_rnd_pred, m, "sales", "price", min_periods=100, steps=100) x = np.array(range(len(cumu_sens))) plt.plot(x/x.max(), cumu_sens, label=m)plt.hlines(sensitivity(prices_rnd_pred, "sales", "price"), 0, 1, linestyles="--", color="black", label="Avg. Sens.")plt.xlabel("% of Top Sensitivity. Days")plt.ylabel("Cumulative Sensitivity")plt.title("Cumulative Sensitivity Curve")plt.legend();
누적 민감도 곡선은 해석하기가 조금 까다로울 수 있는데, 저는 이렇게 봅니다. 이번에도 이진 처치로 생각하면 더 쉬울 것입니다. 곡선의 X축은 표본을 얼마나 처치하는지를 나타냅니다. 여기서는 축을 데이터셋 대비 비율로 정규화했으니 0.4는 표본의 40%를 처치한다는 뜻입니다. Y축은 그만큼 처치할 때 기대할 수 있는 민감도입니다. 따라서 곡선 값이 40%에서 -1이라면 상위 40% 대상의 민감도가 -1이라는 뜻입니다. 이 사례에서 이상적인 것은 가능한 한 큰 표본에서 가장 덜 음수인 민감도를 얻는 것입니다. 그러니 이상적인 곡선은 Y축 높은 곳에서 출발해 평균 민감도까지 아주 천천히 내려옵니다. 대상을 많이 처치하면서도 민감도의 크기를 작게 유지할 수 있다는 뜻입니다.
굳이 말할 필요도 없지만, 이상적인 민감도 곡선에 근접한 모델은 하나도 없습니다. 무작위 모델 rand_m은 평균 민감도 주변에서 오르내릴 뿐 그리 멀리 벗어나지 않습니다. 평균과 민감도가 다른 그룹을 찾아내지 못한다는 뜻입니다. 예측 모델 pred_m은 곡선이 평균 민감도보다 아래에서 시작하므로 민감도를 거꾸로 정렬하는 것으로 보입니다. 게다가 표본의 50% 정도에서 꽤 빨리 평균 민감도로 수렴합니다. 마지막으로 인과 모델 sensitivity_m이 더 흥미롭습니다. 처음에는 누적 민감도가 평균에서 멀어지며 올라가는 이상한 모습을 보입니다. 하지만 그 뒤로는 0에 가까운 꽤 괜찮은 민감도를 유지한 채 대상의 약 75%까지 처치할 수 있습니다. 아마 이 모델이 민감도가 아주 낮은(가격 민감도가 높은) 날을 찾아낼 수 있어서일 것입니다. 그러니 그런 날만 가격을 올리지 않는다면, 표본 대부분(약 75%)에서는 가격을 올려도 가격 민감도를 낮게 유지할 수 있습니다.
모델 평가 측면에서 누적 민감도 곡선은 구간별 민감도라는 단순한 아이디어보다 이미 훨씬 낫습니다. 이 곡선 덕분에 모델의 우열을 훨씬 정밀하게 가릴 수 있었습니다. 그래도 여전히 이해하기 복잡한 곡선입니다. 그래서 한 단계 더 개선해 보겠습니다.
누적 이득 곡선
다음 아이디어는 누적 민감도를 아주 간단하면서도 강력하게 개선합니다. 바로 누적 민감도에 표본 비율을 곱하는 것입니다. 예를 들어 누적 민감도가 40% 지점에서 -0.5라면 그 지점의 값은 -0.2(-0.5 * 0.4)가 됩니다. 그런 다음 이 값을 무작위 모델이 만드는 이론적 곡선과 비교합니다. 이 곡선은 사실 0에서 평균 처치효과까지 이어지는 직선입니다. 이렇게 생각해 보세요. 무작위 모델은 데이터를 무작위로, 대표성 있게 나눌 뿐이므로 무작위 모델의 누적 민감도는 모든 지점에서 ATE입니다. (0,1) 구간의 각 지점에서 ATE에 그 지점 값을 곱하면 0과 ATE를 잇는 직선이 나옵니다.
이론적인 무작위 곡선이 있으면 이를 벤치마크로 삼아 다른 모델들과 비교할 수 있습니다. 모든 곡선은 같은 지점에서 시작해서 같은 지점에서 끝납니다. 하지만 모델이 민감도를 잘 정렬할수록 0과 1 사이 지점에서 곡선이 무작위 직선으로부터 더 멀리 벗어납니다. 예를 들어 위 그림에서 M2는 끝점에서 ATE에 도달하기 전에 더 크게 벗어나므로 M1보다 낫습니다. ROC 곡선에 익숙하다면 누적 이득 곡선(cumulative gain curve)을 인과 모델을 위한 ROC라고 생각하면 됩니다.
def cumulative_gain(dataset, prediction, y, t, min_periods=30, steps=100): size = dataset.shape[0] ordered_df = dataset.sort_values(prediction, ascending=False).reset_index(drop=True) n_rows =list(range(min_periods, size, size // steps)) + [size]## add (rows/size) as a normalizer. return np.array([sensitivity(ordered_df.head(rows), y, t) * (rows/size) for rows in n_rows])
아이스크림 데이터에서는 다음과 같은 곡선을 얻습니다.
코드
plt.figure(figsize=(10,6))for m in ["sensitivity_m_pred", "pred_m_pred", "rand_m_pred"]: cumu_gain = cumulative_gain(prices_rnd_pred, m, "sales", "price", min_periods=50, steps=100) x = np.array(range(len(cumu_gain))) plt.plot(x/x.max(), cumu_gain, label=m)plt.plot([0, 1], [0, sensitivity(prices_rnd_pred, "sales", "price")], linestyle="--", label="Random Model", color="black")plt.xlabel("% of Top Sensitivity Days")plt.ylabel("Cumulative Gain")plt.title("Cumulative Gain")plt.legend();
이제 인과 모델(sensitivity_m)이 나머지 두 모델보다 훨씬 낫다는 게 분명히 드러납니다. rand_m이나 pred_m보다 무작위 직선에서 훨씬 크게 벗어납니다. 또 실제 무작위 모델이 이론적 무작위 모델을 아주 가깝게 따라간다는 점에도 주목하세요. 둘의 차이는 아마 무작위 잡음일 뿐일 것입니다.
이렇게 해서 인과 모델을 평가하는 아주 멋진 아이디어 몇 가지를 살펴봤습니다. 이것만으로도 대단한 성과입니다. 비교할 참값이 없는데도 모델이 민감도를 얼마나 잘 정렬하는지 평가해 냈으니까요. 이제 빠진 것은 딱 하나, 이 측정값에 신뢰구간(confidence interval)을 붙이는 일입니다. 우리가 야만인은 아니지 않습니까?
힌트옮긴이
그림은 ’신뢰구간 없는 선형회귀 계수를 볼 때의 나’라는 밈입니다. 『스타워즈 에피소드 3: 시스의 복수』에서 오비완 케노비가 블래스터를 내던지며 “참으로 야만적이군(So uncivilised)”이라고 내뱉는 장면을 가져왔습니다.
분산 고려하기
민감도 곡선을 다루면서 분산을 고려하지 않는 건 영 찜찜합니다. 특히 이 곡선들은 모두 선형회귀 이론을 바탕으로 하므로 신뢰구간을 붙이기도 꽤 쉬울 것입니다.
이를 위해 먼저 선형회귀 계수의 신뢰구간(CI)을 반환하는 함수를 만들겠습니다. 여기서는 단순선형회귀 공식을 사용하지만, 원하는 방법으로 신뢰구간을 구해도 좋습니다.
def sensitivity_ci(df, y, t, z=1.96): n = df.shape[0] t_bar = df[t].mean() beta1 = sensitivity(df, y, t) beta0 = df[y].mean() - beta1 * t_bar e = df[y] - (beta0 + beta1*df[t]) se = np.sqrt(((1/(n-2))*np.sum(e**2))/np.sum((df[t]-t_bar)**2))return np.array([beta1 - z*se, beta1 + z*se])
cumulative_sensitivity_curve 함수를 조금만 고치면 민감도의 신뢰구간을 출력할 수 있습니다.
def cumulative_sensitivity_curve_ci(dataset, prediction, y, t, min_periods=30, steps=100): size = dataset.shape[0] ordered_df = dataset.sort_values(prediction, ascending=False).reset_index(drop=True) n_rows =list(range(min_periods, size, size // steps)) + [size]# just replacing a call to `sensitivity` by a call to `sensitivity_ci`return np.array([sensitivity_ci(ordered_df.head(rows), y, t) for rows in n_rows])
마지막으로, 인과 모델의 누적 민감도 곡선을 95% 신뢰구간과 함께 그리면 다음과 같습니다.
코드
plt.figure(figsize=(10,6))cumu_gain_ci = cumulative_sensitivity_curve_ci(prices_rnd_pred, "sensitivity_m_pred", "sales", "price", min_periods=50, steps=200)x = np.array(range(len(cumu_gain_ci)))plt.plot(x/x.max(), cumu_gain_ci, color="C0")plt.hlines(sensitivity(prices_rnd_pred, "sales", "price"), 0, 1, linestyles="--", color="black", label="Avg. Sens.")plt.xlabel("% of Top Sens. Days")plt.ylabel("Cumulative Sensitivity")plt.title("Cumulative Sensitivity for sensitivity_m_pred with 95% CI")plt.legend();
데이터셋을 더 많이 누적할수록 신뢰구간이 점점 좁아지는 것을 볼 수 있습니다. 표본 크기가 커지기 때문입니다.
누적 이득 곡선도 똑같이 간단하게 신뢰구간을 구할 수 있습니다. 이번에도 sensitivity 함수를 호출하는 부분을 sensitivity_ci 함수 호출로 바꾸기만 하면 됩니다.
def cumulative_gain_ci(dataset, prediction, y, t, min_periods=30, steps=100): size = dataset.shape[0] ordered_df = dataset.sort_values(prediction, ascending=False).reset_index(drop=True) n_rows =list(range(min_periods, size, size // steps)) + [size]return np.array([sensitivity_ci(ordered_df.head(rows), y, t) * (rows/size) for rows in n_rows])
인과 모델로 그려 보면 다음과 같습니다. 이번에는 곡선 초반의 표본 크기가 더 작은데도 신뢰구간이 좁게 시작한다는 점에 주목하세요. 정규화 계수 \(\frac{k}{N}\)이 ATE 추정값을 축소하면서 그 신뢰구간도 함께 축소하기 때문입니다. 이 곡선은 모델을 비교하는 데 쓰는 것이므로 문제가 되지 않습니다. 평가하는 모든 모델에 이 축소 계수가 똑같이 적용되니까요.
코드
plt.figure(figsize=(10,6))cumu_gain = cumulative_gain_ci(prices_rnd_pred, "sensitivity_m_pred", "sales", "price", min_periods=50, steps=200)x = np.array(range(len(cumu_gain)))plt.plot(x/x.max(), cumu_gain, color="C0")plt.plot([0, 1], [0, sensitivity(prices_rnd_pred, "sales", "price")], linestyle="--", label="Random Model", color="black")plt.xlabel("% of Top Sensitivity Days")plt.ylabel("Cumulative Gain")plt.title("Cumulative Gain for sensitivity_m_pred with 95% CI")plt.legend();
핵심 요약
이 장에서는 모델이 민감도를 얼마나 잘 정렬하는지 확인하는 세 가지 방법을 살펴봤습니다. 그리고 이 방법들로 인과적 목적을 가진 모델들을 비교해 그중 하나를 골랐습니다. 정말 대단한 일입니다. 민감도를 볼 수 없는데도 모델이 민감도가 서로 다른 그룹을 잘 찾아내는지 확인해 냈으니까요!
여기서는 무작위 데이터에 크게 기댔습니다. 모델은 비무작위 데이터로 학습했지만, 평가는 모두 처치가 무작위로 배정된 표본에서 했습니다. 민감도를 믿을 만하게 추정할 방법이 필요했기 때문입니다. 무작위 데이터가 없으면 여기서 쓴 간단한 공식들은 통하지 않습니다. 이제는 잘 알다시피, 교란 요인이 있으면 단순선형회귀에는 누락 변수 편향(omitted variable bias)이 생깁니다.
그래도 무작위 데이터를 조금이라도 손에 넣을 수 있다면, 그 데이터로 모델을 비교하는 방법은 이미 알고 있습니다. 다음 장에서는 비무작위 데이터 문제를 다루겠지만, 넘어가기 전에 모델 평가에 대해 마지막으로 몇 마디 하고 싶습니다.
신뢰할 수 있는 모델 평가가 얼마나 중요한지 다시 강조하겠습니다. 누적 이득 곡선 덕분에 드디어 인과추론에 쓰는 모델을 비교할 좋은 방법이 생겼습니다. 이제 처치 개인화에 어떤 모델이 더 적합한지 결정할 수 있습니다. 이건 큰 진전입니다. 인과추론 자료 대부분은 모델을 평가할 좋은 방법을 알려 주지 않습니다. 제 생각에는 이것이야말로 인과추론이 머신러닝만큼 대중화되는 데 필요한 빠진 퍼즐 조각입니다. 좋은 평가 방법이 있으면 예측 모델에서 이미 큰 효과를 본 훈련-테스트 패러다임에 인과추론을 더 가까이 가져갈 수 있습니다. 대담한 주장이라는 건 압니다. 그래서 조심스럽게 말하지만, 이 주장을 뒤집을 만한 좋은 반론은 아직 찾지 못했습니다. 반론이 있다면 꼭 알려 주세요.
참고 자료
여기 쓴 내용은 대부분 제 머릿속에서 나온 것입니다. 경험으로 배운 것들입니다. 즉, 좋은 과학이라면 보통 거치는 학계의 엄밀한 검증을 통과하지 않았다는 뜻입니다. 대신 실무에서 통하는 것들을 이야기하면서도 왜 그런지 설명하는 데는 시간을 많이 들이지 않았다는 점을 눈여겨보세요. 말하자면 길거리 과학인 셈입니다. 하지만 이 내용을 공개해 누구나 검토할 수 있게 해 두었으니, 터무니없는 내용을 발견하면 주저하지 말고 이슈를 열어 주세요. 최선을 다해 답하겠습니다.
이 장의 아이디어는 Pierre Gutierrez와 Jean-Yves G’erardy의 논문 Causal Inference and Uplift Modeling A review of the literature에서 얻었습니다. 저자들은 Qini 곡선이라는 개념을 설명합니다. 찾아보면 업리프트 모델링(uplift modeling)에서 쓰는 기법이라는 걸 알 수 있는데, 업리프트 모델링은 처치가 이진일 때의 인과추론이라고 생각하면 됩니다. 여기서는 Qini 곡선의 아이디어를 가져와 연속형 처치로 일반화했습니다. 여기서 소개한 방법이 연속형과 이진형 모두에 통한다고 생각하지만, 그렇긴 해도 다른 곳에서 이런 방법을 본 적은 없으니 그 점은 염두에 두세요.
훈련-테스트 패러다임에 관해서는 Leo Breiman(2001)의 논문 Statistical Modeling: The Two Cultures도 꼭 읽어 보길 권합니다. 통계 기법이 무엇 덕분에 성공하는지 이해하고 싶다면 훌륭한 자료입니다.
기여하기
Causal Inference for the Brave and True는 과학의 통계학이라 할 수 있는 인과추론을 다루는 오픈소스 자료입니다. 파이썬 기반의 무료 소프트웨어만 사용하며, 돈이 없어도, 배경지식이 많지 않아도 누구나 볼 수 있는 자료를 목표로 합니다. 이 책이 도움이 되었고 후원하고 싶다면 Patreon을 방문해 주세요. 금전적으로 기여하기 어렵다면 오타를 고치거나, 수정할 부분을 제안하거나, 이해하기 어려웠던 부분에 의견을 주는 것으로도 도울 수 있습니다. 책의 저장소에서 이슈를 열어 주세요. 마지막으로 이 내용이 마음에 들었다면 도움이 될 만한 사람들과 공유하고 GitHub에서 스타를 눌러 주세요.
의견 나누기
번역이 어색한 곳, 더 쉬운 설명, 궁금한 점을 남겨 주세요. GitHub 계정으로 로그인하면 댓글을 달 수 있고, 남긴 의견은 GitHub Discussions에 장별로 모입니다.