← posts

설명 가능한 이상치 탐지 (Isolation Forest & SHAP)

,

보통 이상치를 찾을 때 단변량(변수가 하나밖에 없는) 상황이라면 간단하게 z-score, IQR 등으로 분포에서 멀리 떨어진 관측치를 찾아 볼 수 있습니다. 만약 변수가 매우 많은 다차원의 데이터를 가지고 있다면? 유사한 접근으로 마할라노비스 거리와 같은 방식을 사용해볼 수도 있겠고, 그 외에도 다양한 방식이 가능할 텐데요. 오늘은 그 중 간편하게 적용해볼 수 있는 트리 기반 방법을 소개해보려고 합니다. 그리고 SHAP이 제공하는 플랏들을 사용해서 그 이상치 탐지의 결과를 설명해보는 것까지 같이 해보겠습니다.

Isolation Forest 🌳

아이디어

동어반복 같지만 Isoation Forest는 Forest입니다. 포레스트라는 것은, 널리 알려진 랜덤 포레스트처럼 여러 개의 의사결정나무(Decision Tree)가 모여 있는 앙상블 모델이라는 뜻입니다. 일반적으로 랜덤 포레스트 내에서 각자의 의사결정나무는 정해진 라벨에 대해 주어진 데이터를 가지고 최적의 결정 경계를 학습하는 방식으로 작동을 하는데요, Isolation Forest 속의 의사결정나무 iTree(논문에서는 이 모델을 간단히 iForest, 각 나무를 iTree라고 부릅니다.)는 각 관측치를 따로따로 고립시키는 것을 목적으로 훈련됩니다. 이때 라벨은 필요하지 않기 때문에 이 모델은 비지도학습 방식입니다.

고립(isolate)시키는 것은 말 그대로 특정 개체를 나머지 개체로부터 완전히 구분짓는 것을 의미합니다. 이 모델이 이상치 탐지 목적으로 사용될 수 있는 핵심 아이디어는, 정상적인 관측치라면 고립시키기 어려울 것이고 이상한 관측치라면 고립시키기 쉬울 것이다! 라는 가정입니다.

이 그림을 보면 조금 더 이해하기 쉽겠습니다. 좌측(a)처럼 다른 관측치들 한가운데에 위치한 정상치는, 오로지 이 값만 속하는 외딴 구역을 만들기 위해서 굉장히 여러 번의 경계짓기(split)가 필요합니다. 반면 우측(b)처럼 홀로 떨어진 이상치는 비교적 적은 수의 split만 해도 쉽게 고립시킬 수 있습니다.

예를 들어 전체 학생이 300명이 있고, 반/키/사는 곳/취미/성적 등등 다양한 정보가 있는데, 그 300명 중 키가 190cm가 넘는 학생이 단 1명밖에 없다면 그 학생을 분리시키는 데에는 키≥190cm 이 조건 하나만 있으면 되겠죠. 반면 다른 많은 특성이 나머지 학생들과 비슷한 친구가 있다면, 우리는 그보다 더 많은 조건을 나열해야 그게 누구인지 특정할 수 있을 것입니다. “3반에 왜 그 누구냐 키 중간 정도고…성적도 중간 정도고…축구 좋아하고… 학교 앞에 대형 아파트단지 살고…” (=누군지 전혀 모르겠음)

작동 방식

전반적인 구조는 랜덤 포레스트와 비슷합니다. 랜덤 포레스트가 랜덤하게 선택한 피쳐로 여러 개의 의사결정나무를 훈련시켜서 그 결과를 앙상블하는 것처럼, iForest는 샘플링한 데이터를 가지고 랜덤한 피쳐로 훈련되는 iTree의 결과를 합친 것입니다.

그러면 (랜덤 포레스트의 트리와 달리) 각각의 iTree는 무엇을 할까요? 위에서 언급했듯 iTree의 목표는 모든 관측치를 외딴 섬으로 분리시키는 것입니다. 다만 비지도학습이기 때문에 우리가 미리 가지고 있는 이상치에 대한 정보나 특징 같은 건 없습니다. 그래서 각각의 iTree는 샘플링한 데이터를 가지고 랜덤한 변수에 대해 랜덤한 split을 반복합니다. 매번 고른 변수의 최소값과 최대값 사이에서 uniform한 학률로 split point를 고르는데, 이것을 모든 관측치가 고립될 때까지 또는 정해진 횟수만큼 반복하는 것입니다. 그리고 이때 각 관측치가 고립되기까지의 **경로 길이(루트 노드로부터 최종 고립시키는 split까지의 depth)**를 매번 저장해 둡니다.

랜덤 split을 반복하는 것이 너무 아무렇게나 하는 거 아닌가? 싶어도, 이 짓을 여러 번 한다면(=앙상블한다면) 결국 이상치는 비교적 쉽게 분리되게 되어 있으며, 일정 이상 반복할 경우 robust한 결과를 내게 되어 있다는 점이 이 모델의 핵심입니다.

위 그림은 이 모델이 iTree가 아닌 iForest여야 하는 이유를 보여 줍니다. tree의 개수가 늘어날수록 정상치든 이상치든 각각의 경로 길이의 평균이 일정한 값으로 수렴하는 걸 볼 수 있죠. 즉 나무를 많이 쓰면 쓸수록 각 데이터 포인트의 평균 경로 길이가 안정적이게 됩니다. 논문의 결과에서는 50~100개 정도의 구간에서 값이 안정화되는 것으로 나오네요.

점수 산출

그래서 우리가 궁금한 것은 결론적으로 각 관측치가 이상치냐 아니냐입니다. iForest는 이 질문에 대한 답을 앞서 언급한 경로 길이를 가지고 계산한 0부터 1사이의 점수로 나타냅니다. 이 점수가 바로 각 관측치가 ‘얼마나 이상한지’를 나타내는 값이 됩니다.

공식은 이렇습니다.

s(x,n)=2−E(h(x))c(n)s(x,n) = 2^{-\frac{E(h(x))}{c(n)}}

케이스를 나눠서 살펴보면요,

즉 점수가 커질수록 이상치에 가깝고, 0.5 정도까지는 정상에 가깝다고 볼 수 있겠습니다.

장점

iForest가 지니는 장점은 다음과 같습니다.

써보자!

from sklearn.ensemble import IsolationForest
from sklearn.datasets import load_wine()
import pandas as pd  

d = load_wine()
df = pd.DataFrame(data = d.data, columns=d.feature_names)

당연히 IForest를 불러와주고요, 오늘은 와인 데이터를 사용해보겠습니다. 13개의 피쳐를 사용하여 와인 등급 분류를 목적으로 하는 데이터인데 지금은 타겟값은 필요가 없을 것 같습니다. 여느 싸이킷런 모델처럼 간단하게 다음과 같이 fit 메서드를 이용해 써볼 수 있습니다.

iForest = IsolationForest(n_estimators = 100,
                          contamination = 0.01,
                          #contamination = 'auto',
                          max_samples = 30,
                          bootstrap = False,
                          max_features = 5,
                          random_state = 42
                          )
iForest.fit(df)

파라미터

결과로 다음과 같이 predict와 score_samples 메서드를 사용해서 이상치 점수와 contamination 조정에 따른 이상치 라벨을 받을 수 있습니다.

y_pred = iForest.predict(df)
y_score = iForest.score_samples(df)
df['anomaly_label']= y_pred
df['anomaly_score'] = y_score
df[df['anomaly_label']==-1].head(5)

2차원으로 축소한 후 그림을 그려 봅시다.

outliers = df.loc[df.anomaly_label==-1]
outlier_index = list(outliers.index)
 
from sklearn.decomposition import PCA
import matplotlib.pyplot as plt

pca = PCA(2)
pca.fit(df)
res = pd.DataFrame(pca.transform(df))
plt.title("IsolationForest")
b1 = plt.scatter(res[0], res[1], c='gray', s=20, label="normal")
b1 = plt.scatter(res.iloc[outlier_index,0],res.iloc[outlier_index,1], c='red',s=20, edgecolor="red",label="outliers")
plt.legend(loc="upper right")
plt.show()

설명을 어떻게 하지?

만약 이걸 사용하는 사람의 목적이 분석 결과에 영향을 줄 만한 이상치를 제외하고 싶은 거면 여기서 그냥 멈춰도 됩니다. 대충 이상치 점수 높은 데이터는 제외하고 그다음에 진짜 하고 싶은 걸 하면 되겠죠. 하지만 이상치를 찾아서 그 다음에 뭔가를 하고 싶은 거라면, 아니면 단순히 빼는 데에도 뭔가 설득과 의사결정이 필요하다면, 그때는 이상이냐 아니냐를 보는 것만큼이나 설명이 중요해지겠습니다.

여기서 설명이란 다음 질문에 대답하는 것입니다.

걔가 왜/어떻게 이상한데?

제가 도입부에 들었던 예시라면, 걔는 키가 195cm라서 이상하다고 대답하면 될 것입니다. 수많은 변수 중 그 친구를 가장 쉽게 구분짓는 건 키라고, 보통 걔만큼 키가 크지 않다고 말입니다.

하지만 많은 경우 다차원의 데이터셋을 가지고 특정한 이상치에 대해서 그렇게 정확하고 명쾌한 대답을 하는 건 쉽지가 않습니다. 저 위의 예제에서도 제가 2차원으로 축소한 다음에 플랏을 그렸는데요. 저 빨간 점의 데이터들 중 하나를 집어서 물어본다면, 그 관측치가 정확히 뭐가 어떻게 이상한지를 말할 수 있을까요? 당연히 못합니다. PCA 자체가 13개를 2개로 줄여서 데이터의 특성을 표현을 할 수는 있는데, 원래 변수랑 엄밀히 맵핑은 안 되죠. 하지만 우리가 의사결정에 직관적으로 사용할 수 있는 건 원래 변수고요.

그러니까 많은 머신러닝 모델들처럼, 이번에도 우리는 이게 이상한데 왜 이상한지는 잘 모르겠어 (혹은 간단히 말하긴 좀 그래) 라는 상황에 봉착하게 된 것입니다.

SHAP (SHapley Additive exPlanation)

Shapley Value는 게임이론을 기반으로 게임 내 각 참가자가 기여한 정도를 계산해서 지불금을 주자는 개념에서 출발했습니다(Shapley라는 사람이 처음 언급했다고 합니다). 이것을 설명 가능한 AI(XAI; eXplainable AI)의 관점에서 풀어 쓰자면, 각 관측치에 대한 모델의 예측(=게임)을 가지고 각 피쳐(=참가자)가 그 예측에 대해 기여한 정도를 보자는 것입니다. 이를 위해서 Shapley Value는 모든 가능한 피쳐의 부분집합을 만들어서 각 피쳐가 있을 때와 없을 때의 차이를 기여분으로 계산하고 이를 가중평균합니다. 다만 이를 계산하기 위해서는 계산 비용이 매우 크므로, SHAP은 이런 Shapley Value의 근사값을 구하는 여러 가지 방식을 제공해 줍니다(Tree/Kernel/Deep SHAP 등).

결과 위주로 간단히 말하면 SHAP은 예측에 대한 각 피쳐의 영향력을 방향과 크기로 표현해주는 방법이라고 할 수 있겠는데요. 오늘은 해당 방법 자체를 상세히 보는 것보다는 이것을 사용해 Isolation Forest의 결과를 설명하는 데 중점을 두고 적어보겠습니다. SHAP에 대한 자세한 설명은 여기를 참고하실 수 있습니다.

iForest + SHAP

SHAP은 여러 가지 종류의 Explainer를 제공하는데, 신경망이나 트리 모델에 특화된 Explainer가 있는 반면, 어떤 종류의 모델에도 적용할 수 있는 Explainer가 있는데요(KernelExplainer와 Explainer). 오늘은 TreeExplainer를 사용해보도록 하겠습니다. 인풋으로 아까 훈련한 Isoation Forest 모델과 원 데이터를 넣어주고, 다음과 같이 SHAP 값들을 계산해 줍니다.

import shap
explainer = shap.TreeExplainer(model = iForest, data = df)
shap_values = explainer.shap_values(df)

각 관측치에 대한 설명

SHAP이 제공하는 가장 즉각적인 설명은, 관측치 하나하나에 대해 모델이 그런 (이상치이다/아니다) 예측을 내리는 데 어떤 피쳐가 가장 크게 기여했는지를 보여줄 수 있다는 점입니다.

우선 이상치 중 하나의 인덱스를 골라서 shap_values를 확인해보면, 피쳐 개수와 동일한 13개의 값이 들어있는 array를 확인할 수 있습니다.

print(shap_values[69])

이 값들이 바로 모델의 아웃풋에 대한 각 피쳐의 기여도와 기여 방향을 의미하는데요. 이걸 그림으로 보면 조금 더 편합니다.

shap.force_plot(explainer.expected_value, shap_values[110],feature_names = d.feature_names)

이 관측치의 경우 파란 변수들의 기여도가 훨씬 더 높았고, 빨간 변수들의 길이는 그에 비해 너무 짧아 최종적으로 낮은 예측값(-1에 더 가까운 값)을 얻었습니다. 그리고 이 관측치를 이상치로 만든 변수들 중 가장 큰 영향을 끼친 것은 마그네슘 함량임을 알 수 있습니다.

이번엔 다른 이상치 값을 봅시다.

경로 길이 평균/이상치 점수만 봤을 때는 위의 관측치와 비슷했지만, SHAP이 제공해준 변수별 기여도 값을 보면 위 관측치와 ‘다른 방식으로’ 이상하다는 것을 알 수 있습니다. 이 경우에는 마그네슘이 아닌 다른 변수(회분의 알칼리도)가 매우 높다는 것이 이 관측치를 이상치로 예측하는 데 큰 영향을 끼쳤죠.

아래는 정상 데이터로 같은 플랏을 그려본 것입니다.

이 관측치의 경우에는 빨간색(예측값을 증가시키는 데 기여한) 변수들의 기여도가 훨씬 더 크다는 것을 확인할 수 있습니다.

완전히 동일한 내용을 다음과 같이 Waterfall Plot으로 그릴 수도 있습니다. 이때 max_display 값을 통해 기여도가 큰 순으로 몇 개까지 보여줄 건지도 정할 수 있고요.

shap.waterfall_plot(shap.Explanation(values=shap_values[4],
                    base_values=explainer.expected_value,
                    feature_names=d.feature_names,
                    max_display = 8)
                    )

전체 피쳐에 대한 설명

SHAP은 이런 각 관측치단위 설명 외에도 굉장히 다양한 관점의 설명을 제공합니다. 그 중 전체 데이터에 대해서 피쳐들의 전반적인 변수 기여도를 비교해볼 수 있는 플랏도 있습니다.

shap.summary_plot(shap_values, df)
shap.dependence_plot(1, shap_values, df)
shap.dependence_plot(2, shap_values, df)

말산 - 갚이 커질수록 섀플리값이 음수 방향으로 절대값이 커진다

회분 - 값이 작아질수록 섀플리값이 음수 방향으로 절대값이 커진다

이런 경향성 말고 그냥 전반적인 피쳐 기여도(중요도)만 간단하게 보고 싶으면, 아래와 같이 섀플리 값 절대값의 평균을 바 플랏으로 그려 봐도 됩니다.

shap.plots.bar(shap.Explanation(shap_values,feature_names=d.feature_names), max_display=14)

참고한 글들


Comments