본문 바로가기

문돌이 존버/데이터 분석

(Explainable AI) LIME 실습 코드 돌려보기

반응형
본 글은 투빅스 블로그를 참고하여 작성한 것임을 미리 말씀드립니다.

오늘은 파이썬에 구현된 LIME 라이브러리를 직접 실행해보도록 하겠습니다. 크게 텍스트이미지 데이터에 대해 LIME을 돌려보고 그 결과를 해석하려고 합니다.

LIME with 텍스트 데이터

먼저, 사이킷런에서 제공하는 20가지의 카테고리를 포함하는 뉴스 기사를 대상으로 LIME for Text 과정을 소개합니다.

import tensorflow as tf 
 
gpus = tf.config.experimental.list_physical_devices('GPU')
if gpus:
    # 텐서플로가 두 번째 GPU만 사용하도록 제한
    try:
        tf.config.experimental.set_visible_devices(gpus[1], 'GPU')
    except RuntimeError as e:
        # 프로그램 시작시 접근 가능한 장치가 설정되어야 함
        print(e)
GPU를 사용할 시 아래 CNN 모델을 실행할 때 에러가 발생할 수 있습니다. 
"Failed to get convolution algorithm. This is probably because cuDNN failed to initialize, so try looking to see if a warning log message was printed above"
이럴 경우 위의 코드를 이용해 특정 GPU를 잡아줘야 합니다. 저의 경우 멀티 GPU를 사용하고 있었기 때문에 두 번째 GPU를 잡아주었습니다.
단일 GPU일 경우 또 다른 코드가 있으니 더 자세한 내용은 공식 문서를 참고해주세요.
# 필요 모듈 불러오기
import pandas as pd
import numpy as np
import re

from sklearn.datasets import fetch_20newsgroups
train = fetch_20newsgroups(subset='train')
test = fetch_20newsgroups(subset='test')
# fetch_20newsgroups 객체는 dictionary 형태로 5개의 key를 가지고 있음
train.keys()

각 key에는 여러 개의 value들이 리스트 형태로 되어있습니다. 이해를 돕기 위해 뉴스 기사에 속하는 "data" 와 카테고리를 담고 있는 "target" 을 가지고 데이터프레임으로 표현했습니다.

# 학습셋 데이터프레임화해서 살펴보기
df = pd.DataFrame({'data': train['data'], 'target': train['target']})
print(df.shape)
df.head()

텍스트를 정수로 인코딩하기 위해 카운트 기반의 TF-IDF를 사용하고, 분류 모델은 나이브 베이즈를 사용하겠습니다.

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics import f1_score
from sklearn.naive_bayes import MultinomialNB

# Document -> Vector
vectorizer = TfidfVectorizer(lowercase=False)
train_vectors = vectorizer.fit_transform(train['data'])
test_vectors = vectorizer.transform(test['data'])

# Naive Bayes Modeling
nb = MultinomialNB(alpha=0.01) # for binary classification <-> GaussianNB(for continous data)
nb.fit(train_vectors, train['target'])

# Evaluation
pred = nb.predict(test_vectors)
f1_score(test['target'], pred, average='weighted')

위의 과정을 파이프라인으로 만들고 테스트셋의 데이터 하나에 대한 확률 예측값을 출력해봅니다.

from sklearn.pipeline import make_pipeline
pipeline = make_pipeline(vectorizer, nb) # 텍스트 데이터 벡터화 및 분류 모델링

predict_classes = pipeline.predict_proba([test['data'][0]]).round(3)[0]
predict_classes

rank = sorted(range(len(predict_classes)), key=lambda x: predict_classes[x], reverse=True)
for idx in rank:
    print('[{:>3}]위\t{:<4}class ({:.1%})'.format(rank.index(idx) + 1, idx, predict_classes[idx]))

이제 LIME 라이브러리를 불러오고 그 중에서도 텍스트 데이터를 위한 모듈을 사용합니다.

# !pip install lime
from lime.lime_text import LimeTextExplainer

explainer = LimeTextExplainer(class_names=train['target_names']) # 파라미터로 1) 특성 선택 방식, 2) BOW 수행 방식, 3) 커널 크기 등 지정 가능
# LIME 객체는 해석하고 싶은 데이터와 흉내내고 싶은 모델을 객체로 받음
exp = explainer.explain_instance(test['data'][0],
                                pipeline.predict_proba,
                                top_labels=1) # 가장 확률이 높은 클래스만 보여줌

LIME의 설명(explanation)을 확인해보겠습니다.

exp.show_in_notebook(text=test['data'][0])

위에서 하이라이트 처리가 된 단어들이 뉴스 기사의 카테고리를 구분하는 데 중요하다고 판단된 특성(feature)들입니다. 이들을 submodular라고 부르며, 분류 모델의 결정 경계를 가장 잘 모사하게 됩니다. 그중에서도 가운데 그림을 살펴보면 각 단어마다 rec.autos로 분류했는지, NOT rec.autos로 분류했는지와 함께 가중치도 나와 있습니다.

feature_weight: 일부 단어가 제거되는 등 변형된 문서와 원본 문서 간의 근접도(proximity)

가중치를 모아 보려면 아래의 코드를 이용하면 됩니다.

# 위에서 top_labels=1 이라고 했기 때문에 해당 라벨이 무엇인지 확인
exp.available_labels()

exp.as_list(label=7)

위에서 봤던 가운데 그림 수치가 나오는 것을 확인할 수 있습니다.

LIME with 이미지 데이터

import matplotlib.pyplot as plt
from skimage.color import gray2rgb, rgb2gray
from sklearn.datasets import fetch_olivetti_faces

# 40명 얼굴 사진 데이터 -> 라벨은 고유한 사람 번호
faces = fetch_olivetti_faces()

# 마찬가지로 dictionary 형태
faces.keys()

# 400개의 64 x 64 이미지
np.array(faces['images']).shape
# LIME은 기본적으로 RGB 채널을 가지는 이미지에 대해 사용 가능
X_vec = np.stack([gray2rgb(iimg) for iimg in faces['images']], 0) # 컬러 변환 이후 아래로 쌓기(0: axis=0)
y_vec = faces['target']

print('X_vec 형태는: ', X_vec.shape, '\n' + 'y_vec 형태는: ', y_vec.shape)

from sklearn.model_selection import train_test_split
from keras.utils import to_categorical

X_train, X_test, y_train, y_test = train_test_split(X_vec, y_vec, test_size=0.2)

y_train = to_categorical(y_train) # one-hot encoding
y_test = to_categorical(y_test)
y_train_num = np.argmax(y_train, axis=1) # one-hot encoding 결과 1인 인덱스 위치 리턴
y_test_num = np.argmax(y_test, axis=1)
to_categorical 함수는 아래와 같이 one-hot 인코딩 형식으로 변환하는 기능을 제공합니다. 더 자세한 내용은 공식 문서를 참고해주세요.
a = to_categorical([0, 1, 2, 3], num_classes=4)
print(a)

이제 케라스의 CNN 모델을 통해 학습하겠습니다.

from keras.models import Sequential
from keras.layers import MaxPooling2D
from keras.layers import Conv2D
from keras.layers import Activation, Dropout, Flatten, Dense
num_classes = 40

# Model Build
model = Sequential()
# 필터 수, (커널 행, 열)
model.add(Conv2D(32, (3, 3), input_shape=[64, 64, 3], padding='same')) # 64 x 64 x 3
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2))) # 출력 이미지 크기를 입력 이미지의 반으로
model.add(Dropout(0.25))

model.add(Conv2D(64, (3, 3), padding='same'))
model.add(Activation('relu'))

model.add(Conv2D(64, (3, 3)))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))

# Fully-connected layer
model.add(Flatten()) # 벡터 형태로 reshape
model.add(Dense(512))
model.add(Activation('relu'))
model.add(Dropout(0.5))
model.add(Dense(num_classes)) # 40개 Class
model.add(Activation('softmax'))

# Model compile
model.compile(loss='categorical_crossentropy',
             optimizer='rmsprop',
             metrics=['accuracy'])
model.summary()

# Training
from keras.callbacks import EarlyStopping, ModelCheckpoint

mc = ModelCheckpoint('best_model.h5', monitor='val_accuracy', save_best_only=True)
es = EarlyStopping(monitor='val_accuracy', verbose=1, patience=50)
model.fit(X_train, y_train, epochs=500, validation_split=0.2, callbacks=[es, mc])

# Evaluation
score = model.evaluate(X_test, y_test)
print('정확도: ', score[1])
print('Loss: ', score[0])

CNN 모델 학습 결과 정확도는 약 93%를 기록했습니다. 이제 본격적으로 LIME의 이미지 모듈을 사용해보겠습니다.

from lime import lime_image
from lime.wrappers.scikit_image import SegmentationAlgorithm

explainer = lime_image.LimeImageExplainer()

이전에 설명드렸듯이 이미지 데이터에 대해서 LIME은 슈퍼픽셀을 필요로 합니다. 기존의 픽셀로 구성되어 있는 이미지 데이터를 큰 조각(segment)으로 나누어야 하는데, 이를 위한 알고리즘이 아래와 같이 존재합니다.

# 이미지를 슈퍼픽셀로 분할하는 알고리즘 설정
# quickshift, slic, felzenswalb 등이 존재
segmenter = SegmentationAlgorithm('slic',
                                   n_segments=100, # 이미지 분할 조각 개수
                                   compactnes=1, # 유사한 파트를 합치는 함수
                                   sigma=1) # 스무딩 역할: 0과 1사이의 float

눈치채셨을지도 모르겠지만 알고리즘에 따라 이후에 LIME이 설명하는 정도가 달라질 수 있습니다. 우선 테스트 데이터 하나를 선택해서 LIME 설명 객체에 필요한 정보를 제공하겠습니다. 기본적으로 설명 모델은 사이킷런의 regressor가 됩니다.

olivetti_test_index = 0
exp = explainer.explain_instance(X_test[olivetti_test_index],
                                classifier_fn=model.predict, # 40개 class 확률 반환
                                top_labels=5, # 확률 기준 1~5위
                                num_samples=1000, # sample space
                                segmentation_fn=segmenter) # 분할 알고리즘
# sklearn의 regressor 기본 설명 모델로 사용

from skimage.color import label2rgb

# 캔버스
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(8, 8))
ax = [ax1, ax2, ax3, ax4]
for i in ax:
    i.grid(False)
# 예측에 가장 도움되는 세그먼트만 출력
temp, mask = exp.get_image_and_mask(y_test_num[0],
                                 positive_only=True, # 설명 모델이 결과값을 가장 잘 설명하는 이미지 영역만 출력
                                 num_features=8, # 분할 영역의 크기
                                 hide_rest=False) # 이미지를 분류하는 데 도움이 되는 서브모듈 외의 모듈도 출력
# label2rgb : 형광색 마스킹
ax1.imshow(label2rgb(mask, temp, bg_label=0), interpolation='nearest')
ax1.set_title('Positive Regions for {}'.format(y_test_num[0]))

# 모든 세그먼트 출력
temp, mask = exp.get_image_and_mask(y_test_num[0],
                                 positive_only=False, # 설명 모델이 결과값을 가장 잘 설명하는 이미지 영역만 출력
                                 num_features=8, # 분할 영역의 크기
                                 hide_rest=False) # 이미지를 분류하는 데 도움이 되는 서브모듈 외의 모듈도 출력

ax2.imshow(label2rgb(4-mask, temp, bg_label=0), interpolation='nearest') # 역변환
ax2.set_title('Positive/Negative Regions for {}'.format(y_test_num[0]))

# 이미지만 출력
ax3.imshow(temp, interpolation='nearest')
ax3.set_title('Show output image only')

# 마스크만 출력
ax4.imshow(mask, interpolation='nearest') # 정수형 array
ax4.set_title('Show mask only')

위의 plot 코드를 통해 살펴본 결과 13번 클래스(=사람)을 판단하기 위해 가장 중요하다고, 즉 가중치가 높은 영역은 코, 눈, 입 주변인 듯 합니다. 총 분할 영역은 8개인데, 이는 위에서 exp.get_image_and_mask(num_features=8) 이라고 설정했기 때문입니다.

from sklearn.metrics import classification_report
y_pred = model.predict(X_test)
y_pred_num = np.argmax(y_pred, axis=1)

print(classification_report(y_true=y_test_num, y_pred=y_pred_num))

위의 지표를 참고하여 잘 예측하지 못한 클래스 중 하나를 살펴보겠습니다.

# score가 낮은 네 번째 클래스(사람) 분석
np.where(y_test_num==4)

%matplotlib inline
# 테스트셋의 18 번째 데이터 이용
test_idx = 18
exp = explainer.explain_instance(X_test[test_idx],
                              classifier_fn=model.predict,
                              top_labels=5,
                               num_samples=1000,
                              segmentation_fn=segmenter)

fig, m_axs = plt.subplots(2,5, figsize=(10, 4))

for i, (c_ax, gt_ax) in zip(exp.top_labels, m_axs.T):
    temp, mask=exp.get_image_and_mask(i,
                                         positive_only=True, # 설명 모델이 결과값을 가장 잘 설명하는 이미지 영역만 출력
                                         num_features=12, # 분할 영역의 크기
                                         hide_rest=False, # 이미지를 분류하는 데 도움이 되지 않는 세그먼트는 출력 x
                                         min_weight=0.001) 
    c_ax.imshow(label2rgb(mask, temp, bg_label=0),
               interpolation='nearest')
    c_ax.set_title('Positive for {}\nScore:{:2.2f}%'.format(i,
                                                           100 * y_pred[test_idx, i]))
    c_ax.axis('off')
    
    face_id = np.random.choice(np.where(y_train_num==i)[0])
    
    gt_ax.imshow(X_train[face_id])
    gt_ax.set_title('Example of {}'.format(i))
    gt_ax.axis('off')

위의 이미지 다섯 장은 네 번째 사람 이미지를 보고 35, 39, 6, 24, 5 번째 사람이라고 잘못 분류한 사례입니다. 그리고 그렇게 판단한 이유, 즉 슈퍼픽셀을 보여주고 있는데 사실 사람이 보기에는 이해가 쉽지 않은 것 같습니다.

39 번째 사람이라고 잘못 분류한 이미지를 보면 코 옆에 팔자주름(?)에 하이라이팅 되어 있는 것으로 보아 이 부분이 비슷하다고 판단했을 것이라 예상이 되네요. 어찌되었든 LIME은 모델이 예측한 결과값에 대해서 왜 그런 결과가 나왔는지를 설명해주는 것입니다. 왜 모델이 잘못 예측했는지를 보려면 이전 단계인 슈퍼픽셀 분할 알고리즘부터 살펴봐야겠죠. 또 그전에 앞서서 데이터 전처리 및 모델링 단계부터 살펴봐야 할 것입니다.

참고
https://github.com/marcotcr/lime/blob/master/doc/notebooks/Lime%20-%20multiclass.ipynb
https://towardsdatascience.com/what-makes-your-question-insincere-in-quora-26ee7658b010
728x90
반응형