【Uplift Modeling】변수의 선택방법에 대하여

해당 포스트는 Zhenyu Zhao at el. "Feature Selection Methods for Uplift Modeling" (2020)의 내용을 기반으로, Uber사에서 개발한 파이썬 오픈소스 패키지 CausalML에 구현된 소스코드들을 다루고 있습다.


* Introduction


Feature Selection 방법은 각 feature에 대해 중요도 점수(importance score)를 계산한 뒤 점수를 바탕으로 랭크를 매긴다. Uplift model은 이렇듯 가장 중요하다고 판단된 변수들만을 가지고 만들어 질 수 있다. Uplift modeling에서 중요한 변수들에게만 집중하는 것은 몇가지의 이득을 가져다 준다.

  1. 훈련을 위한 빠른 계산처리
  2. overfitting 문제를 피함으로써 더욱 정확한 예측이 가능
  3. 데이터 파이프라인의 낮은 유지비용
  4. 더욱 쉬운 모델 해석과 진단

이렇듯 feature selection은 Uplift modeling에 있어서 중요한 문제임에도 불구하고 지금까지 관련 문헌에서 거의 논의되어오지 못했다. 전통적인 머신러닝에 있어서의 변수선택방법의 연구는 V. Bolon-Canedo et al.(2013), G. Chandrashekar & F. Sahin(2014), J. Tang(2014)과 같은 논문들에서 잘 논의되어있지만, 이것들은 Uplift modeling에서 최적의 변수선택 방법은 아니다.

이 논문에서는 방법론적이고 경험적인 평가 관점에서 변수선택을 다룬다.


* 일반적인 변수선택 방법과의 관계


일반적인 변수선택법의 종류
  1. filter methods
  2. wrapped methods
  3. embedded methods
일반적인 변수선택법이 Uplift modeling에서 최선이 아닌 이유
  • 분류문제를 생각했을때 일반적 변수선택법의 목적은 feature에 기반하여 outcome이 각 클래스에 해당될 확률의 예측하는 것이다. 그러므로 feature의 중요도는 클래스 확률과 관계가 깊다.

  • 반면에 Uplift model의 목적은 CATE를 예측하는 것이다. 그러므로 여기서 좋은 feature는 클래스 확률이 아니라 치료효과를 예측할 수 있게 해주는 것이어야 한다. 이 두가지 예측대상이 항상 일치할 필요는 없으므로 Uplift modeling에서 일반적 변수선택법은 최선이 아닐 수 있다.



* uplift modeling을 위한 변수선택 방법


예시로써 x1 , ... , x63 의 총 63개 feature 가 존재하는 가상의 data를 생각하자.


A. Filter Methods

이 방법은 각 feature에 대하여 치료효과와 feature간의 한계관계(marginal relationship)를 기반으로 중요도 점수(Importance score)를 계산한다. 이것은 한번에 하나의 feature에 대한 간단한 계산만 이루어지므로 빠른 전처리 단계이다.

1. F filter
- Causalml package에서 implementation
1
2
3
4
5
6
7
from causalml.feature_selection.filters import FilterSelect
filter_f = FilterSelect()
method = 'F'
f_imp = filter_f.get_importance(df, X_names, y_name, method,
treatment_group = 'treatment1')
# X_names는 features의 컬럼리스트
f_imp

[output]


- 코드의 동작 원리를 자세하게 살펴보자.

치료여부변수(\(Z\))와 확인하고자하는 feature(\(x_i\)) 그리고 그들의 교호작용항(interaction term)을 사용하여 outcome변수를 예측하는 선형회귀모델이다.

\(y_{outcome} = \beta_0 + \beta_1Z + \beta_2x_i + \beta_3(Z\cdot x_i)\)

중요도 점수(importance score)는 교호작용항 \(\beta_3(Z\cdot x_i)\)의 계수\(\beta_3\)에 대한 F-통계값 으로 정의된다. 이 통계값이 크면 해당 feature는 강한 heterogeneous treatment effect와 상관이 있다는 것을 의미한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
method = 'F'

# treatment_indicator column 생성 (treatment : 1 , control : 0)
df = df[df['treatment_group_key'].isin(['control', 'treatment1'])]
df['treatment_indicator'] = 0
df.loc[df['treatment_group_key']=='treatment1','treatment_indicator'] = 1

all_result = pd.DataFrame()
for x_name_i in X_names:
Y = df['conversion']
X = df[['treatment_indicator', x_name_i]]
X = sm.add_constant(X)
X['{}-{}'.format('treatment_indicator', x_name_i)] = X[['treatment_indicator', x_name_i]].product(axis=1)

model = sm.OLS(Y, X)
result = model.fit()
# 교호작용의 중요도를 알고싶다
# 교호작용항의 계수 β_3에 대해서만 f검정
F_test = result.f_test(np.array([0, 0, 0, 1]))
F_test_result = pd.DataFrame({
'feature': x_name_i,
'method': 'F-statistic',
'score': F_test.fvalue[0][0],
'p_value': F_test.pvalue,
'misc': 'df_num: {}, df_denom: {}'.format(F_test.df_num, F_test.df_denom),
}, index=[0]).reset_index(drop=True)

one_result = F_test_result

all_result = pd.concat([all_result, one_result])

all_result = all_result.sort_values(by='score', ascending=False)
all_result['rank'] = all_result['score'].rank(ascending=False)

all_result['method'] = method + ' filter'

all_result[['method', 'feature', 'rank', 'score', 'p_value', 'misc']]

[output]


각 feature 별로 F 통계량을 기준으로 scoring해서 점수가 높은 순으로 내림차순으로 반환한다.


2. LR filter (Likelihood ratio)
- Causalml package에서 implementation
1
2
3
4
method = 'LR'
lr_imp = filter_f.get_importance(df, X_names, y_name, method,
treatment_group = 'treatment1')
lr_imp

[output]


여기서는 score를 로지스틱 회귀모형의 교호작용항 계수에 대한 likelihood ratio 검정 통계량으로 정의한다. 각 feature 별 LR 통계량을 기준으로 scoring해서 점수가 높은 순으로 내림차순으로 반환한다.


3. Filter method with K bins

여기에는 Piotr Rzepakowski & Szymon Jaroszewicz (2012)에서 제안된 uplift tree의 분할기준으로 부터 세가지 방법이 존재한다.


3-1. Kullback-Leibler divergence
- Causalml package에서 implementation
1
2
3
4
method = 'KL'
kl_imp = filter_f.get_importance(df, X_names, y_name, method,
treatment_group = 'treatment1',
n_bins=10)

[output]


3-2. the squared Euclidean distance
- Causalml package에서 implementation
1
2
3
4
method = 'ED'
kl_imp = filter_f.get_importance(df, X_names, y_name, method,
treatment_group = 'treatment1',
n_bins=10)

[output]


3-3. the Chi-squared divergence
- Causalml package에서 implementation
1
2
3
4
method = 'Chi'
kl_imp = filter_f.get_importance(df, X_names, y_name, method,
treatment_group = 'treatment1',
n_bins=10)

[output]


- 코드의 동작 원리를 자세하게 살펴보자.

주어진 feature에 대해 이 방법은 먼저 샘플을 feature의 백분위를 기준으로 K개의 bin으로 나눈다. (여기서 K는 하이퍼파라미터) 중요도 점수는 이러한 K개의 bins에 대한 처리효과의 divergence measure로 정의된다.

구체적으로, outcome 변수에 C개의 클래스가 있다고 가정해보자.

\(P_k = (p_{k1},...,p_{kC})\)\(Q_k = (q_{k1},...,q_{kC})\)가 각각 치료군과 대조군의 \(k\)번째(\(k=1,...,K\)) bin에서의 클래스별 sample의 비율이라고 했을 때, 중요도 점수는 이하와 같이 정의된다.

\(\Delta = \sum^K_{k=1}\cfrac{N_k}{N}D(P_k:Q_k)\)

\(N_k\) : \(k\)번째 bin의 샘플사이즈
\(N\) : 전체 샘플 사이즈
\(D\) : distribution divergence
- Kullback-Leibler divergence (denoted as KL )
- the squared Euclidean distance(denoted as ED )
- the chi-squared divergence (denoted as Chi )

  • \(KL(P_k:Q_k)=\sum^n_{i=1}p_{ki}log\cfrac{p_{ki}}{q_{ki}}\)


  • \(ED(P_k:Q_k)=\sum^n_{i=1}(p_{ki}-q_{ki})^2\)


  • \(\chi^2(P_k:Q_k)=\sum^n_{i=1}\cfrac{(p_{ki}-q_{ki})^2}{q_{ki}}\)



[먼저 세개의 함수를 정의]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
def _GetNodeSummary(data,
experiment_group_column='treatment_group_key',
y_name='conversion'):
"""
To count the conversions and get the probabilities by treatment groups. This function comes from the uplift tree algorithm, that is used for tree node split evaluation.

Parameters
----------
data : DataFrame
The DataFrame that contains all the data (in the current "node").

Returns
-------
results : dict
Counts of conversions by treatment groups, of the form:
{'control': {0: 10, 1: 8}, 'treatment1': {0: 5, 1: 15}}
nodeSummary: dict
Probability of conversion and group size by treatment groups, of
the form:
{'control': [0.490, 500], 'treatment1': [0.584, 500]}
"""

# Note: results and nodeSummary are both dict with treatment_group_key
# as the key. So we can compute the treatment effect and/or
# divergence easily.

# Counts of conversions by treatment group
results_series = data.groupby([experiment_group_column, y_name]).size()

treatment_group_keys = results_series.index.levels[0].tolist()
y_name_keys = results_series.index.levels[1].tolist()

results = {}
for ti in treatment_group_keys:
results.update({ti: {}})
for ci in y_name_keys:
results[ti].update({ci: results_series[ti, ci]})

# Probability of conversion and group size by treatment group
nodeSummary = {}
for treatment_group_key in results:
n_1 = results[treatment_group_key][1]
n_total = (results[treatment_group_key][1]
+ results[treatment_group_key][0])
y_mean = 1.0 * n_1 / n_total
nodeSummary[treatment_group_key] = [y_mean, n_total]

return results, nodeSummary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Divergence-related functions, from upliftpy
def _kl_divergence(pk, qk):
"""
Calculate KL Divergence for binary classification.

Parameters
----------
pk (float): Probability of class 1 in treatment group
qk (float): Probability of class 1 in control group
"""
if qk < 0.1**6:
qk = 0.1**6
elif qk > 1 - 0.1**6:
qk = 1 - 0.1**6
S = pk * np.log(pk / qk) + (1-pk) * np.log((1-pk) / (1-qk))
return S
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def _evaluate_KL(nodeSummary, control_group='control'):
"""
Calculate the multi-treatment unconditional D (one node)
with KL Divergence as split Evaluation function.

Parameters
----------
nodeSummary (dict): a dictionary containing the statistics for a tree node sample
control_group (string, optional, default='control'): the name for control group

Notes
-----
The function works for more than one non-control treatment groups.
"""
if control_group not in nodeSummary:
return 0
pc = nodeSummary[control_group][0]
d_res = 0
for treatment_group in nodeSummary:
if treatment_group != control_group:
d_res += _kl_divergence(nodeSummary[treatment_group][0], pc)
return d_res



[Kullback-Leibler divergence를 이용한 Scoring의 과정]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
method = 'KL'
n_bins = 10

all_result = pd.DataFrame()

for x_name_i in X_names:

totalSize = len(df.index)
x_bin = pd.qcut(df[x_name_i].values, n_bins, labels=False,
duplicates='raise')
d_children = 0

for i_bin in range(x_bin.max() + 1): # range(n_bins):
nodeSummary = _GetNodeSummary(
data=df.loc[x_bin == i_bin],
experiment_group_column='treatment_group_key', y_name='conversion'
)[1]
nodeScore = _evaluate_KL(nodeSummary,
control_group='control')
nodeSize = sum([x[1] for x in list(nodeSummary.values())])
d_children += nodeScore * nodeSize / totalSize

parentNodeSummary = _GetNodeSummary(
data=df, experiment_group_column='treatment_group_key', y_name='conversion'
)[1]
d_parent = _evaluate_KL(parentNodeSummary,
control_group='control')

d_res = d_children - d_parent

one_result = pd.DataFrame({
'feature': x_name_i,
'method': method,
'score': d_res,
'p_value': None,
'misc': 'number_of_bins: {}'.format(min(n_bins, x_bin.max()+1)),# format(n_bins),
}, index=[0]).reset_index(drop=True)

all_result = pd.concat([all_result, one_result])

all_result = all_result.sort_values(by='score', ascending=False)
all_result['rank'] = all_result['score'].rank(ascending=False)

all_result['method'] = method + ' filter'

all_result[['method', 'feature', 'rank', 'score', 'p_value', 'misc']]

[output]



B. Embedded Methods

이 방법은 uplift model를 훈련시킬 때 나오는 부산물로 변수의 중요성을 얻는다. 이것은 meta-learner과 uplift tree 둘다에서 얻어질 수 있다.

- Meta-learner

feature 중요도는 base-learner로 부터 얻어진다.
예를 들어 Two Model approach 에서는 feature의 중요도점수는 두 base-learner가 산출한 embedding된 중요도 점수의 합으로 정의될 수 있다.


- Uplift tree

feature에 대한 중요도 점수는 Tree에서 Tree node가 분할되는 동안의 손실함수에 대한 누적기여로 정의할 수 있다. 이는 대상이 특별한 분할 기준이 있는 Uplift tree라는 점을 제외하면 일반적으로 잘 알려진 classification tree의 embedded feature 중요도와 유사하다. 각 분할에서 우리는 distribution divergence 의 증가분(gain) 을 계산한다.

\(\Delta = \sum_{k=left,\space right} D(P_k:Q_k)- D(P:Q)\)
(\(P,Q\)는 각각 치료군과 대조군의 Outcome distribution)

feature의 중요도 점수는 해당 feature가 사용된 노드 분할로 부터 발생하는 모든 \(\Delta\)를 더하는 것으로 계산할 수 있다.


이후 생략된 내용 ;

위에서 소개한 변수선택 방법들의 평가 (synthetic data & real-world data), 논문이 실제 적용에서 추천하는 방법 등

Comments