파이썬으로 업리프트 모델링

출처: 아리가 미치아키, 머신러닝 실무 프로젝트, 2018, 한빛미디어

업리프트 모델링(Uplift modeling)은 역학통계에서 사용되는 기법으로, 무작위 대조시험의 결과를 분석합니다. 이를 응용해 신약 개발에서 환자의 연령 및 성별, 유전자등을 특성으로 머신러닝을 수행하고, 부작용이 예상되는 환자를 미리 제외해 개인화된 의료 서비스 제공을 기대할 수 있습니다.

  • 무작위 대조시험: 모집단을 무작위로 실험군과 대조군으로 나누는 것을 말합니다. 예를 들어 신약 개발에서 실험군에는 신약을 투여하고 대조군에는 가짜 약을 투여하는 것 입니다.

0. 데이터 설명

과거 12개월 동안 구매이력이 있는 고객을 대상으로 '남성 타겟 판촉 메일', '여성 타겟 판촉 메일', '메일을 발송하지 않음'을 수행하고 이 행동이 사이트 방문으로 이어졌는지 조사한 것입니다.

필드명 내용
recency 마지막 구매 기록으로부터 경과한 기간
history_segment 최근 1년 구매 금액에 따라 분류한 유형
history 최근 1년 구매 금액
mens 최근 1년 동안 남성용 제품 구매 여부
womens 최근 1년 동안 여성용 제품 구매 여부
zip_code 우편번호
newbies 신규고객 여부
channel 최근 1년동안 구매한 경로
segment 고객에게 보낸 판촉 메일 유형
visit 메일 수신후 2주 내 사이트 방문여부
conversion 메일 수신후 2주내 상품 구매 여부
spend 메일 수신후 2주내 구매 금액

1. 데이터 불러오기

먼저 데이터 파일을 pandas를 이용해 읽어 들이고 구조를 확인합니다.

In [1]:
import pandas as pd

source_df = pd.read_csv("./data/Kevin_Hillstrom_MineThatData.csv")
source_df.tail()
Out[1]:
recency history_segment history mens womens zip_code newbie channel segment visit conversion spend
63995 10 2) $100 - $200 105.54 1 0 Urban 0 Web Mens E-Mail 0 0 0.0
63996 5 1) $0 - $100 38.91 0 1 Urban 1 Phone Mens E-Mail 0 0 0.0
63997 6 1) $0 - $100 29.99 1 0 Urban 1 Phone Mens E-Mail 0 0 0.0
63998 1 5) $500 - $750 552.94 1 0 Surburban 1 Multichannel Womens E-Mail 0 0 0.0
63999 1 4) $350 - $500 472.82 0 1 Surburban 0 Web Mens E-Mail 0 0 0.0

2. 데이터 정리하기

2.1. 불필요한 데이터 제거

메일을 받지 않은 고객의 데이터는 필요하지 않음으로 제거합니다.

In [2]:
mailed_df = source_df[source_df["segment"] != "No E-mail"].reset_index(drop=True)
mailed_df.tail()
Out[2]:
recency history_segment history mens womens zip_code newbie channel segment visit conversion spend
63995 10 2) $100 - $200 105.54 1 0 Urban 0 Web Mens E-Mail 0 0 0.0
63996 5 1) $0 - $100 38.91 0 1 Urban 1 Phone Mens E-Mail 0 0 0.0
63997 6 1) $0 - $100 29.99 1 0 Urban 1 Phone Mens E-Mail 0 0 0.0
63998 1 5) $500 - $750 552.94 1 0 Surburban 1 Multichannel Womens E-Mail 0 0 0.0
63999 1 4) $350 - $500 472.82 0 1 Surburban 0 Web Mens E-Mail 0 0 0.0

데이터의 형태를 확인해 봅니다.

In [3]:
mailed_df.dtypes
Out[3]:
recency              int64
history_segment     object
history            float64
mens                 int64
womens               int64
zip_code            object
newbie               int64
channel             object
segment             object
visit                int64
conversion           int64
spend              float64
dtype: object

위의 결과에서 zip_code, channe, segment가 object인것을 볼 수 있습니다. segment는 label로 사용될 것입니다.

2.2. 원핫인코딩(one-hot encoding)

zip_code, channel 값은 범주형임으로 one-hot encoding을 수행합니다.

In [4]:
dummied_df = pd.get_dummies(mailed_df[["zip_code", "channel"]], drop_first=True)
#  필요없는 행을 지웁니다.
feature_vector_df = mailed_df.drop(
    [
        "history_segment",
        "zip_code",
        "channel",
        "segment",
        "visit",
        "conversion",
        "spend",
    ],
    axis=1,
)
feature_vector_df = feature_vector_df.join(dummied_df)
feature_vector_df.head()
Out[4]:
recency history mens womens newbie zip_code_Surburban zip_code_Urban channel_Phone channel_Web
0 10 142.44 1 0 0 1 0 1 0
1 6 329.08 1 1 1 0 0 0 1
2 7 180.65 0 1 1 1 0 0 1
3 9 675.83 1 0 1 0 0 0 1
4 2 45.34 1 0 0 0 1 0 1

남성 타겟 메일을 받은 고객들 중 사이트에 방문한 데이터를 분리해 줍니다.

In [5]:
is_treat_list = list(mailed_df["segment"] == "Mens E-Mail")
is_cv_list = list(mailed_df["visit"] == 1)

3. 학습하기

먼저, 데이터를 학습용과 테스트 데이터로 나누어 줍니다.

In [6]:
from sklearn.model_selection import train_test_split

(
    train_is_cv_list,
    test_is_cv_list,
    train_is_treat_list,
    test_is_treat_list,
    train_feature_vector_df,
    test_feature_vector_df,
) = train_test_split(
    is_cv_list, is_treat_list, feature_vector_df, test_size=0.5, random_state=42
)

이 예제에서는 전송받은 이메일에 의한 사이트 방문여부를 예측하는 것이므로 LogisticRegression을 이용해 학습을 하겠습니다.

In [7]:
from sklearn.linear_model import LogisticRegression

treat_model = LogisticRegression(C=0.01)
control_model = LogisticRegression(C=0.01)

train_sample_num = len(train_is_cv_list)

treat_is_cv_list = [
    train_is_cv_list[i]
    for i in range(train_sample_num)
    if train_is_treat_list[i] is True
]
treat_feature_vector_list = train_feature_vector_df[train_is_treat_list]

control_is_cv_list = [
    train_is_cv_list[i]
    for i in range(train_sample_num)
    if train_is_treat_list[i] is False
]
control_feature_vector_list = train_feature_vector_df[
    list(map(lambda a: a is False, train_is_treat_list))
]

treat_model.fit(treat_feature_vector_list, treat_is_cv_list)
control_model.fit(control_feature_vector_list, control_is_cv_list)
Out[7]:
LogisticRegression(C=0.01, class_weight=None, dual=False, fit_intercept=True,
          intercept_scaling=1, max_iter=100, multi_class='ovr', n_jobs=1,
          penalty='l2', random_state=None, solver='liblinear', tol=0.0001,
          verbose=0, warm_start=False)

4. 모델 평가하기

학습한 모델을 평가하기 위한 점수 시스템을 만들어보겠습니다. 업리프트 모델링에서는 treat_model, control_model로 부터 두개의 예측값을 얻게 됩니다 . 업리프트 모델링 점수는 실험군 예측값을 대조군 예측값으로 나눈 비율로 사용하겠습니다. sklearnpredict_proba 함수를 통해 아래와 같이 간단히 할 수 있습니다.

In [8]:
from operator import itemgetter

treat_score = treat_model.predict_proba(test_feature_vector_df)  # 실험군 예측값
control_score = control_model.predict_proba(test_feature_vector_df)  # 대조군 예측값
score_list = treat_score[:, 1] / control_score[:, 1]  # 업리프트 모델링 점수계산

result = list(zip(test_is_cv_list, test_is_treat_list, score_list))
result.sort(key=itemgetter(2), reverse=True)  # 점수가 높은 순으로 정렬

4.1. 백분위수 단위로 나타낸 전환율

먼저 점수가 큰 순서대로 정렬하고 백분위수마다 점수를 계산해봅니다.

In [9]:
import matplotlib.pyplot as plt

%matplotlib inline

qdf = pd.DataFrame(columns=("treat_cvr", "control_cvr"))

quantile_data = []
for n in range(10):
    start = int(n * len(result) / 10)  # 결과를 10% 단위로 나눈다
    end = int((n + 1) * len(result) / 10) - 1
    quantiled_result = result[start:end]
    # 실험군과 대조군에서 결과 모으기
    treat_uu = list(map(lambda item: item[1], quantiled_result)).count(True)
    control_uu = list(map(lambda item: item[1], quantiled_result)).count(False)
    # 실험군과 대조군의 전환건수 세기
    treat_cv = [item[0] for item in quantiled_result if item[1] is True].count(True)
    control_cv = [item[0] for item in quantiled_result if item[1] is False].count(True)

    treat_cvr = treat_cv / treat_uu
    control_cvr = control_cv / control_uu

    quantile_data.append(
        [treat_uu, control_uu, treat_cv, control_cv, treat_cvr, control_cvr]
    )

    label = "{}%~{}%".format(n * 10, (n + 1) * 10)
    qdf.loc[label] = [treat_cvr, control_cvr]

qdf.plot.bar()
plt.xlabel("percentile")
plt.ylabel("conversion rate")
Out[9]:
<matplotlib.text.Text at 0x7f060b4c23c8>
No description has been provided for this image

백분위수 단위로 전환율을 시각화해보니 점수가 상위 60% 까지는 남성 타깃 메일을 보낸 쪽이 여성 타깃 메일을 보낸 쪽보다 반응이 좋음을 알 수 있습니다. 반대로 하위 40%까지는 여성 타깃 메일이 반응이 좋습니다. 그러므로 상위 60%는 남성 타깃 메일을 보내고 하위 40%는 여성 타깃 메일을 보내는 것이 좋음을 알 수 있습니다.

4.2. AUC로 평가하기

  1. 점수가 높은 순서대로 각 대상을 훑으면서 매 시점의 점수를 측정합니다.
  2. 점수의 값 차이로부터 개입이 일으킨 증가 건수(lift)를 계산합니다.
  3. lift의 원점과 끝점을 지나는 직선을 베이스라인으로 삼습니다.
  4. lift와 베이스라인 사이의 면적을 계산합니다.
In [10]:
treat_uu = 0
control_uu = 0
treat_cv = 0
control_cv = 0
treat_cvr = 0.0
control_cvr = 0.0
lift = 0.0
stat_data = []

for is_cv, is_treat, score in result:
    if is_treat:
        treat_uu += 1
        if is_cv:
            treat_cv += 1
        treat_cvr = treat_cv / treat_uu
    else:
        control_uu += 1
        if is_cv:
            control_cv += 1
        control_cvr = control_cv / control_uu
    lift = (treat_cvr - control_cvr) * treat_uu
    stat_data.append(
        [
            is_cv,
            is_treat,
            score,
            treat_uu,
            control_uu,
            treat_cv,
            control_cv,
            treat_cvr,
            control_cvr,
            lift,
        ]
    )

df = pd.DataFrame(stat_data)
df.columns = [
    "is_cv",
    "is_treat",
    "score",
    "treat_uu",
    "control_uu",
    "treat_cv",
    "control_cv",
    "treat_cvr",
    "control_cvr",
    "lift",
]

df["base_line"] = df.index * df["lift"][len(df.index) - 1] / len(df.index)
f, ([ax0, ax1], [ax2, ax3]) = plt.subplots(
    nrows=2, ncols=2, sharex=True, figsize=(10, 8)
)

df.plot(y=["treat_cv", "control_cv"], ax=ax0)
ax0.set_xlabel("uplift score rank")
ax0.set_ylabel("conversion count")
df.plot(y=["treat_cvr", "control_cvr"], ylim=[0, 0.3], ax=ax1)
ax1.set_xlabel("uplift score rank")
ax1.set_ylabel("conversion rate")
df.plot(y=["lift", "base_line"], ax=ax2)
ax2.set_xlabel("uplift score rank")
ax2.set_ylabel("lift count")
ax3.axis("off")
plt.tight_layout()
No description has been provided for this image

데이터를 정규화한 그림을 다시 그립니다.

In [11]:
f, ([ax0, ax1], [ax2, ax3]) = plt.subplots(
    nrows=2, ncols=2, sharex=True, figsize=(10, 8)
)
df.plot(y=["treat_cv", "control_cv"], x="score", title="conversion count", ax=ax0)
df.plot(
    y=["treat_cvr", "control_cvr"],
    ylim=[0, 0.3],
    x="score",
    title="conversion rate",
    ax=ax1,
)
df.plot(y=["lift", "base_line"], x="score", title="lift", ax=ax2)
ax3.axis("off")
plt.tight_layout()
No description has been provided for this image

업리프트 모델링의 결과가 정확할 수록 방문하는 실험군의 고객과 방문하지 않은 대조군의 고객이 높은 점수대로 모입니다. 그리고 낮은 점수대에서는 반대현상이 일어납니다. 이 때문에 lift곡선은 실험군에서 방문하는 고객에 해당하는 초반 부분에서 양의 기울기를 가지며 정확도가 높을 수록 이 기울기가 가파릅니다. 같은 이유로 곡선의 후반 부분은 음의 기울기를 갖습니다. 또한 업리프트의 모델링의 정확도가 높을수록 이 기울기도 가팔라집니다. 결국 업리프트 모델링의 결과가 정확할수록 곡선의 형태가 볼록해지며 lift와 베이스라인 사이의 면적이 넓어지기 때문에 AUC값이 커지게 됩니다.

이 점수를 기초로 개입여부를 결정할 수 있습니다. lift점수가 최고점일때 개입하면 됩니다.

5. 마치며

업리프트 모델링을 소개하고 예를 살펴보았습니다. 업리프트 모델링은 무작위 대조시험과 고객 정보로 부터 추출한 특징을 조합하여, 효과가 좋은 고객을 예측해주는 모델을 구축하는 기법입니다. 이 기법을 사용하면 비용을 절감하고 효과를 최대화 할 수 있습니다.