tak's data blog

피파온라인4 - 인과관계를 고려한 이탈 분석(PSM 매칭기법) 본문

프로젝트/인과관계를 고려한 피파4 이탈분석

피파온라인4 - 인과관계를 고려한 이탈 분석(PSM 매칭기법)

hyuntaek 2022. 8. 4. 20:35
반응형
SMALL

 

 

 

전에 작성했었던 자료에 이어서 분석을 진행하도록 하겠습니다!

https://taek98.tistory.com/66

 

피파온라인4 이탈, 진성유저 분석_이탈유저는 nextfield에 적응할까?

배경 선정 이탈 분석이라는 주제를 잡고 진성, 이탈 유저들의 플레이 특징을 위주로 파악해보려고 하였다. 우선 피파온라인4는 특성상 nextfield라는 체감 개선 업데이트가 거의 반기별로 이루어

taek98.tistory.com

 

 

우선 아래와 같은 방법을 위해 ncsoft의 데이터분석 블로그를 참고했다는 점을 말씀드리고 싶습니다.

참고: https://danbi-ncsoft.github.io/works/2020/06/17/works-pk_analysis.html

 

PK를 당한 유저는 게임에서 이탈할까?

 

danbi-ncsoft.github.io

매번 읽어보면서 참 많이 배워가곤 하네요...

 

 

지난 시간에 피파온라인4를 분석하면서 이탈유저가 진성유저에 비해 전체적인 슛, 패스 등의 스텟이 낮아 이탈유저는 nextfield에 적응하지 못해 이탈할 것이다! 라는 결론을 내렸었습니다.

 

 

하지만 다음과 같은 부분에서 마음이 걸렸습니다.

1. 단순 수치들로 판단을 했다는 점과

2. 레벨, 구단가치, division(월드클래스, 프로, 세미프로 등)에 따라 실력과 스텟이 좌지우지 될 수도 있다는 점

 

 

그래서

위의 게시물을 참고하여

X가 Y의 원인이 아니라 반대로 Y가 X의 원인이 되는 경우인 '역 인과 관계'와 

X와 Y 양쪽에 영향을 미치는 제 3의 요인인 교란 요인을 고려하며 분석하기로 하였습니다.

 

위의 요인들을 고려하여

단순히 두 집단 사이의 이탈율에 차이가 있는지 여부만 볼 것이 아니라 이 차이가 통계적으로 유의한지 확인해야 합니다.

 

 

0. 문제 정의

문제정의: nextfield에 적응하지 못하는 유저는 이탈할까?

 

위에서 언급했듯이 스텟(슛성공률, 패스성공률)이라는 수치는 선수와 그 사람의 실력이 많은 영향을 끼친다는 점을 깨달았습니다.

 

ex) 스텟이 더 좋은 EBS굴리트(요즘 카드)를 사용하는 유저는 NH굴리트(옛날 카드)를 사용하는 유저보다 더욱 높은 슛 성공률을 보일 것입니다. (구단가치와도 연관이 있겠죠??) 

ex) 또한 적응을 잘해 높은 구간에 있는 사람이어도 금방 게임에 질려 이탈할 수 있을 것입니다. 

 

그래서 저는 오직 적응/비적응이 이탈에 어느정도의 영향을 주는지 파악하기 위해

통제 변인을 다음과 같이 정의하였습니다.

- 레벨(오래 플레이한 유저의 실력에 따른)

- 시즌 등급(등급에 따른 스텟 적응의 차이)

- controller(키보드, 패드)(방향 전환이나 드리블이 부드러운 패드의 특성 등)

- 전적(승,무,패)

 

 

1. 데이터 구조

nextfield7에 플레이한 유저 1,021명을 기준으로 분석하였습니다.

- 이탈 기준은 저번과 동일

- 적응/비적응의 기준은 아래의 스텟을 기준으로 평균보다 높으면 적응 집단(325명) / 낮으면 비적응 집단(696명)으로 구성

(7차 nextfield 업데이트 내역)

상향: 드리블, 헤딩슛 정확도 향상, 패스미스 개선

하향: 침투 하향(스루패스와의 연관성)

 

 

2. 분석 방법

교란 요인을 통제하기 위해 '성향 점수 매칭(Propensity Score Matching, PSM)’이라는 기법을 사용했습니다.

이는 실험군(적응 집단), 대조군(비적응 집단)에 속한 개체들의 여러 특징을 대표하는 수치(성향 점수)를 만든 후, 실험군에 속한 개체들 각각의 점수와 동일하거나 비슷한 값을 갖는 대조군 개체들을 비교 대상으로 선정하는 기법이라고 합니다. 

이렇게 선정하고 나면 오직 결국 적응/비적응에 대한 이탈여부 만을 파악할 수 있을 것입니다.

 

이 과정은 로지스틱 회귀분석을 통해 실시하였습니다.

참고자료: https://m.blog.naver.com/paperfactor_ceo/222098513280

 

 

2-1. EDA

아래와 같은 데이터에서(스텟관련 추가 칼럼들도 존재합니다!)

아래와 같이 범주형 변수를 처리하였습니다.

# 범주형변수 처리
df['churn'] = df['churn'].apply(lambda x: 1 if x == 'O' else 0)
df['적응'] = df['적응'].apply(lambda x: 1 if x == 'O' else 0)

# api 메타정보를 활용한 등급 할당
df.loc[(df['division'] == 900)|(df['division'] == 1000), 'division'] = '챔피언스'
df.loc[(df['division'] == 1100)|(df['division'] == 1200)|(df['division'] == 1300), 'division'] = '챌린지'
df.loc[(df['division'] == 2000)|(df['division'] == 2100)|(df['division'] == 2200), 'division'] = '월드클래스'
df.loc[(df['division'] == 2300)|(df['division'] == 2400)|(df['division'] == 2500), 'division'] = '프로'
df.loc[(df['division'] == 2600)|(df['division'] == 2700), 'division'] = '세미프로'

 

 

우선 적응/비적응의 카이제곱검정을 통해 영향을 끼치는지 확인해야 합니다.

# 실험군 대조군 나누기
df_control = df[df.적응==0]
df_적응 = df[df.적응==1]


# 카이제곱 검정을 위한 crosstab
from scipy.stats import chi2_contingency

# 적응/비적응 + 승/무/패에 따른 검정
contingency = pd.crosstab(df['적응'], df['matchResult'])

chi, p = chi2_contingency(contingency)
print(f"chi 스퀘어 값: {chi}", f"p-value (0.05): {p}")

chi 스퀘어 값: 9.44674290365039
p-value (0.05): 0.008885172064580714

-- p-value가 0.05보다 낮으므로 게임 적응과 승부사이에는 관계가 있다.


# 적응/비적응 + controller에 따른 검정
contingency = pd.crosstab(df['적응'], df['controller'])
chi, p = chi2_contingency(contingency)
print(f"chi 스퀘어 값: {chi}", f"p-value (0.05): {p}")

chi 스퀘어 값: 4.0350776439488865
p-value (0.05): 0.0445636246706571

-- p-value가 0.05보다 낮으므로 게임 적응과 controller에는 관계가 있다.


# 적응/비적응 + division에 따른 검정
contingency = pd.crosstab(df['적응'], df['division'])
chi, p = chi2_contingency(contingency)
print(f"chi 스퀘어 값: {chi}", f"p-value (0.05): {p}")

chi 스퀘어 값: 10.656322858372025
p-value (0.05): 0.013737114563445828

-- p-value가 0.05보다 낮으므로 게임 적응과 division에는 관계가 있다.

이렇듯 적응/비적응에 다른 변수들이 영향을 끼침을 확인할 수 있었습니다.

이대로 적응/비적응 집단 간의 교란 요인을 통제하지 못해 로지스틱 회귀분석을 실행했다가는 잘못된 결과를 내놓을 수 있게 됩니다. 

그래서 PSM을 통해 실험군에 속한 개체들 각각의 점수와 동일하거나 비슷한 값을 갖는 대조군 개체들을 비교 대상으로 선정해야 합니다.

 

 

# ttest
from scipy.stats import ttest_ind

print(df_control.level.mean(), df_적응.level.mean())

# compare samples
_, p = ttest_ind(df_control.level, df_적응.level)
print(f'p={p:.3f}')

# interpret
alpha = 0.05  # significance level
if p > alpha:
    print('same distributions/same group mean (fail to reject H0 - we do not have enough evidence to reject H0)')
else:
    print('different distributions/different group mean (reject H0)')


-- p값이 0.05보다 크므로 아래와 같은 결과가 나왔습니다.
380.48994252873564 378.4676923076923
p=0.938
same distributions/same group mean (fail to reject H0 - we do not have enough evidence to reject H0)

 

2-2. 성향점수매칭

그래서 우선 적응/비적응을 종속 변수로 두고 나머지 변수들을 설명 변수들로 구성하여 로지스틱 회귀분석을 실시하여야 합니다.

features = df[['matchResult', 'controller', 'level', 'division']]

# 범주형 더미변수 생성
features = pd.get_dummies(features, drop_first=True)
churn = df['적응']

# train_test_split
from sklearn.model_selection import train_test_split
train_features, test_features, train_labels, test_labels = train_test_split(features, churn)

# 모델링
from sklearn.linear_model import LogisticRegression
model = LogisticRegression()
model.fit(train_features, train_labels)

위의 로지스틱 회귀분석 결과로 아래와 같은 coef가 도출되었습니다.

 

로지스틱 회귀분석이므로 np.exp를 통해 해석할 수 있겠지만 여기서는 Propensity score를 통해 비슷한 실험군-대조군 끼리 짝을 지어 자료를 선정하고 짝을 이루지 못한 것들은 통계분석에서 제외합니다.

 

적응 집단 중 특정 사람의  Logistic regression 값(propensity score)와

비적응 집단 중 특정 사람의 Logistic regression 값(propensity score)이 비슷한 사용자끼리 짝을 짓습니다.

짝을 전부 맺어 두 개의 분포 그래프를 그려보면, 분포의 모양이 비슷해져있을 것이고, 이 분포의 모양이 비슷하게 잘 됐는지 여부로 짝이 잘 맺어졌는지 확인할 수 있을 것입니다.

 

이를 바탕으로, 다시 돌아와 짝이 지어진 실험군을 통해 처음 가정(적응이 이탈에 영향을 주는가?)에 대한 분석을 진행한다면, 적응여부가 이탈에 영향을 미치는 인과성(순수효과)을 알아 볼 수 있을 것입니다.

 

참고자료: https://greend93.tistory.com/3

 

# propensity score를 위한 코드

# prediction
pred_binary = model.predict(features)  # binary 0 control, 1, treatment
pred_prob = model.predict_proba(features)  # probabilities for classes

#print('the binary prediction is:', pred_binary[0])
#print('the corresponding probabilities are:', pred_prob[0])

# the propensity score (ps) is the probability of being 1 (i.e., in the treatment group)
df['ps'] = pred_prob[:, 1]

# calculate the logit of the propensity score for matching if needed
# I just use the propensity score to match in this tutorial
def logit(p):
    logit_value = math.log(p / (1-p))
    return logit_value

df['ps_logit'] = df.ps.apply(lambda x: logit(x))

 

 

빈도수는 차이가 나지만 결국 반응에 따른 분포가 어느정도 비슷함을 확인할 수 있었습니다.

 

 

아래는 구한 propensity score를 기반 + 적응/비적응 집단을 거리기반으로 비슷한 유저끼리 매칭하는 코드입니다.

# 매칭을 위한 코드(knn 기반 적응/비적응 집단에 따른 인덱스 매칭)

# use 25% of standard deviation of the propensity score as the caliper/radius
# get the k closest neighbors for each observations
# relax caliper and increase k can provide more matches

from sklearn.neighbors import NearestNeighbors

caliper = np.std(df.ps) * 0.25
print(f'caliper (radius) is: {caliper:.4f}')

n_neighbors = 10


# setup knn
knn = NearestNeighbors(n_neighbors=n_neighbors, radius=caliper)

ps = df[['ps']]  # double brackets as a dataframe
knn.fit(ps)


# distances and indexes
distances, neighbor_indexes = knn.kneighbors(ps)


# for each point in treatment, we find a matching point in control without replacement
# note the 10 neighbors may include both points in treatment and control

matched_control = []  # keep track of the matched observations in control

for current_index, row in df.iterrows():  # iterate over the dataframe
    if row.적응 == 0:  # the current row is in the control group
        df.loc[current_index, 'matched'] = np.nan  # set matched to nan
    else: 
        for idx in neighbor_indexes[current_index, :]: # for each row in churn, find the k neighbors
            # make sure the current row is not the idx - don't match to itself
            # and the neighbor is in the control 
            if (current_index != idx) and (df.loc[idx].적응 == 0):
                if idx not in matched_control:  # this control has not been matched yet
                    df.loc[current_index, 'matched'] = idx  # record the matching
                    matched_control.append(idx)  # add the matched to the list
                    break
                    
                    
# control have no match
적응_matched = df.dropna(subset=['matched'])  # drop not matched

# matched control observation indexes
control_matched_idx = 적응_matched.matched
control_matched_idx = control_matched_idx.astype(int)  # change to int
control_matched = df.loc[control_matched_idx, :]  # select matched control observations

# combine the matched treatment and control
df_matched = pd.concat([적응_matched, control_matched])

df_matched.적응.value_counts()


0    297
1    297

각각 297명씩 잘 매칭되었음을 확인하였습니다.

 

 

이제 다시 매칭을 한 후 적응/비적응 집단과 범주형 변수에 따른 카이제곱검정으로 차이가 있는지 확인해보도록 하겠습니다.

contingency = pd.crosstab(df_matched['적응'], df_matched['matchResult'])
chi, p = chi2_contingency(contingency)
print(f"chi 스퀘어 값: {chi}", f"p-value (0.05): {p}")

chi 스퀘어 값: 0.19566367001586463
p-value (0.05): 0.9068013831801203

-- p-value가 0.05보다 크므로 귀무가설을 기각하지 못하여 두개의 관계가 없다고 판단됩니다.
-- 두 개의 변수가 독립이라고 판단됩니다.


contingency = pd.crosstab(df_matched['적응'], df_matched['controller'])
chi, p = chi2_contingency(contingency)
print(f"chi 스퀘어 값: {chi}", f"p-value (0.05): {p}")

chi 스퀘어 값: 0.021838235294117648
p-value (0.05): 0.88251824448679

-- p-value가 0.05보다 크므로 귀무가설을 기각하지 못하여 두개의 관계가 없다고 판단됩니다.


contingency = pd.crosstab(df_matched['적응'], df_matched['division'])
chi, p = chi2_contingency(contingency)
print(f"chi 스퀘어 값: {chi}", f"p-value (0.05): {p}")

chi 스퀘어 값: 1.0163935325648636
p-value (0.05): 0.7972852900642861

-- p-value가 0.05보다 크므로 귀무가설을 기각하지 못하여 두개의 관계가 없다고 판단됩니다.

결과적으로 매칭전에는 변수들끼리의 관계가 있었지만, 매칭후에는 독립이라는 결론을 내릴 수 있었습니다.

 

 

그리고 매칭전에는

적응 집단의 이탈율 62.8%
비적응 집단의 이탈율 71.4%

매칭후에는

적응 집단의 이탈율 63.0%
비적응 집단의 이탈율 65.3%

단순비교분석에서도 이탈율의 차이가 줄어들었음 확인하였습니다.

 

 

3. 결과

이제 마지막으로 적응/비적응집단을 설명변수로 두고 이탈여부를 종속변수로 두어 로지스틱회귀분석을 실시하겠습니다.

# scikit-learn을 통한 로지스틱회귀분석
features = df_matched[['적응']]

features = pd.get_dummies(features, drop_first=True)
churn = df_matched['churn']

from sklearn.model_selection import train_test_split
train_features, test_features, train_labels, test_labels = train_test_split(features, churn, 
                                                                           test_size=0.3,
                                                                           random_state=2)

from sklearn.linear_model import LogisticRegression
model = LogisticRegression()
model.fit(train_features, train_labels)


# statsmodels를 통한 로지스틱회귀분석
import statsmodels.api as sm
features2 = sm.add_constant(features)
logit = sm.Logit(churn,features2) #로지스틱 회귀분석 시행
result = logit.fit()

result.summary2()

두가지 방법을 통해 실행할 수 있지만, 꼭 명심하셔야 할 것이 있습니다.

sm을 통해서는 add_constant를 통해 상수항을 추가하여야 더 정확한 결론을 내릴 수 있습니다!

 

 

아래와 같은 결론이 내려졌습니다.

로지스틱회귀분석이므로 -0.1025에 exponential 함수를 취해야 합니다.

np.exp(result.params)

적응       0.902577

즉 적응에 따라 이탈일 확률이 0.9배 증가한다고 해석할 수 있겠습니다. 하지만 여기서 주의할 점이 있습니다! p값이 0.549로 (>0.05) 귀무가설을 기각할 수 없고, 결론적으로 적응/비적응 여부와 이탈은 통계적으로 유의성이 없다고 해석할 수 있습니다.

 

처음 단순 수치분석 + pairplot 등으로 보았을 때는 nextfield에 비적응한 유저는 이탈할 가능성이 더 높다는 결론이 있었습니다. 하지만 PSM을 통한 매치로 이탈율의 차이가 8.6% -> 2.3%로 감소하였고, 로지스틱회귀분석의 결과에서도 적응여부는 이탈에 유의하지 않다는 결론이 나왔습니다.

 

이렇듯 단순수치분석에서는 볼 수 없는 새로운 결과를 도출할 수 있었고, 저의 가설이 틀렸음을 증명할 수 있었습니다.

변수안에서 결과를 방해하는 다른 요인은 무엇인지 찾아보는 과정에서 나은 인사이트를 도출할 수 있었습니다.

 

 

 

4. 배운 점/ 아쉬운 점

배운 점

- 단순수치분석이 아닌 통계적 유의성을 통해 변수의 영향도를 살펴보면서 인사이트를 얻을 수 있었음

- sm을 통한 로지스틱회귀분석시에 상수항을 더해줘야 함!

- PSM이라는 기법을 이해하는데 오래걸렸지만 직접 사용해가며 배운 점

 

아쉬운 점

- 여러 효과에 대해 qqplot 등의 시각화를 이뤄내지 못한 점(계속 찾아보면서 해봐야 겠다)

- 여러 자료를 참고하며 진행하였지만 온전히 내것으로 만든 느낌이 안듬

- 꼭 피드백을 받아보고 싶음(통계 검정은 절차대로 잘 진행했는지 등)

 

 

 

 

참고자료:

성향점수분석

http://freesearch.pe.kr/archives/4377

https://www.anesth-pain-med.org/journal/view.php?id=10.17085/apm.2016.11.2.130

https://danbi-ncsoft.github.io/works/2020/06/17/works-pk_analysis.html

https://www.kaggle.com/code/harrywang/propensity-score-matching-in-python/notebook

https://m.blog.naver.com/paperfactor_ceo/222098513280

https://greend93.tistory.com/3

 

로지스틱회귀분석

https://nittaku.tistory.com/478

 

반응형
LIST