1. 모델 분석/Classification

[당뇨망막병증(Diabetic Retinopathy)에서 Grad-CAM으로 병변 찾기] 1. Data Preprocessing

M_AI 2021. 5. 31. 21:14

 

안녕하세요! M_AI 입니다!

 

이번에는 분류(Classfication) 모델에 대해서, 예측 결과가 왜 그렇게 나오는 지에 대해 파악하고자 Grad-CAM으로 파악하고자 합니다!

 

직전에는 당뇨망막병증(Diabetic Retinopathy) 데이터셋에서 segmentation 하고자 하였습니다.

 

이에 대해 궁금하시면 아래 링크 게시글을 읽어보시길 바랍니다!

 

이전글 : 2D Multi-Class Semantic Segmentation

[1편] : https://yhu0409.tistory.com/7

 

[2D Multi-Class Semantic Segmentation] 1. Data Preprocessing

2021.05.11 (화) 수정 내역 1. 데이터 추가 - 검증 데이터가 적어 모델에 대한 신뢰성이 떨어져, DIARETDB1 데이터셋을 추가. 2. 클래스 변경 - 기존에는 optic disc를 추가하여 함께 segmentation을 했으나 새.

yhu0409.tistory.com

[2편] : https://yhu0409.tistory.com/8

 

[2D Multi-Class Semantic Segmentation] 2. U-Net with Backbone and Loss Function

안녕하세요! M_AI 입니다! 이전글에서는 당뇨망막병증(Diabetic Retinopathy)에서 무엇을 세그멘테이션할 지와 이를 위해 데이터셋을 어떻게 준비하는 지에 대해 설명했습니다. 이번 장에서는 ImageNet

yhu0409.tistory.com

[3편] : https://yhu0409.tistory.com/9

 

[2D Multi-Class Semantic Segmentation] 3. Model Training, Evaluation and Result

안녕하세요! M_AI 입니다! 이전까지 데이터에 대한 설명과 학습을 위한 데이터 전처리 및 데이터셋 준비법을 설명했습니다! 이전글 : [2D Multi-Class Semantic Segmentation] 2. U-Net with Backbone a..

yhu0409.tistory.com

 

이번에는 lesion segmentation이 아닌, 병의 중증도에 대해 분류(classification)을 진행할 것입니다. 데이터셋 종류는 똑같이 Diabetic Retinopathy로 진행할 것이지만, IDRiD가 아닌 다른 데이터로 진행합니다.

 

더 나아가서, Grad-CAM을 통하여 어느 부위(병변)에 의해서 중증도가 결정되는지를 알아보도록 합니다!


이 글을 읽으면 좋을 것 같은 사람들!

- 기본적인 분류(classfication) 모델을 Tensorflow 2.x 버전으로 구현할 줄 아는 사람

- Clssification을 넘어서 Object detection을 하길 원하는데 오픈 소스를 봐도 이해가 가질 않는 사람.

- 오픈 소스를 이해하더라도 입맛대로 수정하는 방법을 모르는 사람.

 

 

본 게시글의 목적

※ 본 게시글은 딥러닝 모델의 상세한 이론적 설명보다는 모델 구조를 바탕으로 코드 분석하고 이해하는 데에 목적에 있습니다!

※ 상세한 이론을 원하면 다른 곳에서 찾아보시길 바랍니다!

 

본 게시글의 특징

① Framework : Tensorflow 2.x

② 학습 환경

- Google Colab

③ Batch size = 1

④ Kaggle에서 APTOS 2019 Blindness Detection 데이터셋과 Diabetic Retinopathy Detection 데이터셋을 사용하여 병의 중증도를 분류(Classification) 및 예측 목적.

( 데이터셋 출처 : https://www.kaggle.com/benjaminwarner/resized-2015-2019-blindness-detection-images)

 

Resized 2015 & 2019 Blindness Detection Images

Resized & Cropped Images from the Blindness & Diabetic Retinopathy Competitions

www.kaggle.com

https://www.kaggle.com/c/aptos2019-blindness-detection

 

APTOS 2019 Blindness Detection

Detect diabetic retinopathy to stop blindness before it's too late

www.kaggle.com

⑤ 게시글에서는 반말


 

개 요

 

본 게시글에서는 분류 모델에서 예측 결과가 어떠한 이유로 그러한 결과를 도출했는지에 대해 파악하고자 Grad-CAM을 사용한다.

 

본래 딥러닝 모델은 블랙 박스(black box)로 해당 모델이 왜 그런 결과를 도출해내는지 알 수 없었으나, 많은 사람이 이를 알아내고자 설명 가능한 인공지능(eXplainable Artificial Intelligence, XAI)를 연구하면서 블랙 박스가 개봉되기 시작했다.

그중 하나가 바로 CAM인데, 우리가 사용할 Grad-CAM은 일반 CAM보다 업그레이드된 개념이다.

이에 대한 상세한 설명은 아래 사이트에서 참고하길 바란다.

 

http://dmqm.korea.ac.kr/activity/seminar/274

 

고려대학교 DMQA 연구실

고려대학교 산업경영공학부 데이터마이닝 및 품질애널리틱스 연구실

dmqa.korea.ac.kr

 

https://jsideas.net/grad_cam/

 

Grad-CAM: 대선주자 얼굴 위치 추적기

a novice's journey into data science

jsideas.net

 

https://you359.github.io/cnn%20visualization/GradCAM/

 

Paper Review - Grad-CAM

What is Grad-CAM? and How to implement?

you359.github.io

 

더 나아가, 본 게시글에서는 당뇨망막병증(Diabetic Retinopathy, DR) 데이터셋을 분류 모델(classification model)을 통하여 병 중증도를 예측하는 모델을 학습할 예정이다. 여기서 추가로 동일한 데이터로 두 모델을 학습하여 성능 비교할 것이다.

 

- VGG16

-EfficientNet B0

 

또한, Grad-CAM으로 영상 데이터에서 왜 그렇게 예측하는지를 각각 파악할 것이다.

 

데이터셋은 kaggle에 공개된 APTOS 2019 Blindness Detection 데이터셋과 Diabetic Retinopathy Detection 데이터셋을 사용할 것이다.

 


 

1. Dataset and Preprocessing

 

본 게시글에서 사용될 데이터셋은 두 가지로 kaggle에 공개된 APTOS 2019 Blindness Detection 데이터셋과 Diabetic Retinopathy Detection 데이터셋이다. 이 데이터셋은 당뇨망막병증(Diabetic Retinopathy)의 중증도에 따라 5개의 클래스로 분류했으며 이는 다음과 같다.

 

0 - No DR

1 - Mild

2 - Moderate

3 - Severe

4 - Proliferative DR

 

보다시피 숫자가 클수록 중증도가 심해진다.

 

이 데이터셋은 완전 raw data로, 영상 크기가 모두 달라 전처리(preprocessing)가 매우 까다롭다. 그래서 kaggle에 Benjamin Warner 라는 사람이 위의 두 데이터셋을 1024px로 리사이즈한 데이터셋을 사용할 것이다. 이에 대한 링크는 위에 있으므로 확인하길 바란다.

 

데이터 구성

- resized train 19

- resized test 19

- resized train 15

- resized test 15

- labels

    - trainLabels19.csv

    - testImages19.csv

    - trainLabels15.csv

    - testLabels15.csv

 

 

이 데이터는 위와 같이 구성되어있다. 뒤에 숫자 15가 있는 데이터는 Diabetic Retinopathy Detection 데이터셋이며, 19는 APTOS 2019 Blindness Detection 데이터셋이다.

확인한 사람들은 알겠지만 해당 데이터셋 용량이 무려 17GB인 것을 알 수 있다.

 

본인은 위에서도 언급했다시피 작업을 Google colab Pro에서 진행하기에, 데이터셋을 Google Drive에 업로드해야만 한다. 하지만 Drive 용량은 15GB로, 데이터셋을 수용하기에 부족하여 이 문제를 해결해야만 한다.

우선은 각 데이터의 각 클래스 별 데이터 수를 확인해보자.

 


resized train 19 resized test 19 resized train 15 resized test 15
0- No DR 1,805 - 25,801 39,533
1- Mild 370 - 2,443 3,762
2- Moderate 999 - 5,292 7,861
3- Severe 193 - 873 1,214
4- Proliferative DR 295 - 708 1,206
Total 3,662 1,928 35,126 53,576

 

위의 표를 보면 Diabetic Retinopathy Detection 데이터셋이 APTOS 2019 Blindness Detection 데이터셋에 비해 확실히 많다는 것을 알 수 있다.

 

여기서 조금 특이한 게 Diabetic Retinopathy Detection 데이터셋에서 test 데이터셋이 train 데이터셋보다 많다는 것이다.

그리고 APTOS 2019 Blindness Detection의 test 데이터셋에는 레이블이 없어, 이를 validation 데이터셋이나 test 데이터셋으로 사용하기에도 애매하여 제외하도록 한다.

하지만 여기서 가장 큰 문제점은 각 클래스 별 데이터 수가 달라 데이터 불균형(data imbalance)이 심하다는 것을 알 수 있다.

 

그리고 각 영상 데이터를 확인하면, Figure 1과 같다.

 

Figure 1. Sample Dataset

 

영상 데이터를 확인하면 다음 두 가지를 알 수 있다.

 

A. 서로 다른 크기의 영상

앞에 1024px에 리사이즈된 데이터셋이라고 했다. 하지만 이는, 정사각형으로 리사이즈된 것이 아닌 폭 길이 1024px에 맞춰 리사이즈된 영상 데이터이다. 또한, 본인이 확인해본 결과 정확히 1024가 아닌 1023에도 맞춰진 데이터도 존재하였다. 그래서 영상 데이터의 위아래와 좌우에 길이 1024에 맞추기 위해 zero padding 및 crop, resize를 진행해주어야 한다.

 

B. 질이 떨어지는 영상

해당 데이터는 raw data이기에 저렇게 초점이 맞지 않거나, 밝기가 매우 어두워 잘 보이지 않는 데이터들이 섞여 있다. 초점이 맞지 않는 것은 제거하고, 어두운 영상은 아래 전처리에서 진행할 예정이다.

 

여기까지의 문제점을 정리하면 다음과 같다.

 

- 매우 큰 데이터 용량

- 심각한 데이터 불균형(Data imbalance)

 

본인은 이를 해결하기 위해서 많은 데이터 중에서 일부만 사용하기로 했으며, 데이터 불균형은 Focal Loss function으로 이 문제를 해결하고자 한다.

 

이 결과 본 게시글에서 사용할 데이터셋은 다음과 같다.

 

- train_dataset

: resized train 15 ( 80% )

 

- valid_dataset

: resized train 15 ( 20% )

 

- test_dataset

: APTOS 2019 Blindness Detection train_images

 

훈련 데이터셋과 검증 데이터셋은 1024px로 리사이즈된 15년도 Diabetic Retinopathy Detection 데이터셋에서 훈련 데이터셋을 사용할 것이다.

 

테스트 데이터셋은 APTOS 2019 Blindness Detection에서 훈련 데이터셋을 사용할 것이다. 이 데이터셋은 리사이즈가 되지 않은 완전 raw한 데이터로 전처리가 필수이다.

 

이렇게 사용할 데이터셋의 총 용량은 14GB이다.

 

여전히 큰 용량이다.

 

하지만 본인은 이 데이터셋을 그대로 사용하지 않을 것이다.

 

PC에서 전처리 데이터 필터링을 거친 후에 데이터를 google drive에 올릴 예정이다.

 

 

1.1. Data Preprocessing

사살 딥러닝에서 학습보다 중요한 것이 바로 데이터 전처리이다.

아무리 성능이 뛰어난 모델이라도, 데이터가 질이 안 좋다면 말짱 도로묵이다.

딥러닝에서 90%가 데이터 전처리라고 해도 과언이 아닐 정도이다. 또한, 노가다 만큼 지루하고 시간이 오래 걸리는 작업이기도 하다.

이 말을 하는 이유는, raw data에서의 전처리가 노가다와 같은 작업이고 이를 이번에 할 것이기 때문이다.

전치리는 진행은 다음과 같다.

 

a. Fundus Image에서 지름 찾기

 

b. 밝기에 대한 정규화

: 이는 Figure 1에서 보다시피 어두운 영상들에 대한 처리이며, 서로 다른 밝기의 영상을 일종의 정규화한다는 개념이다.

방식은 Ben Graham (kaggle 2015 Diabetic Retinopathy Detection 우승자)가 전처리한 방식을 사용한다.

 

c. 필요한 부분을 a에서 구한 지름만큼의 원형으로 Crop

 

a과정이 필요한 이유는 Figure 2를 보면 영상들의 형태가 모두 다르게 생겼다.

 

 

Figure 2. 서로 다른 데이터 형태

 

 

이를 위해서 모두 원형으로 만들 필요가 있지만, Figure 2-(a)에서 높이만큼 원형으로 crop한다면 손실되는 데이터들이 존재한다.

 

그리고 Figure 2-(d)는 높이와 폭 둘 중 어느 것을 지름으로 잡아도 손실이 발생하기 때문에 이를 최대한 보존하면서 crop하는 방법은

 

대각선으로 지름을 찾는 것이다. (이는 (d) 형태로 인해 이런 번거러운 과정을 거친다.)

 

대각선으로 지름 찾는 방식은 다음과 같다.

 

 

a. 지름 찾기

 

- 원본 영상 전체의 높이와 폭으로 actan로 계산하여 폭에 대한 높이 각도(θ)를 구한다.

 

- 원본 영상에 대하여 대각선 길이만큼 상하좌우를 0으로 패딩한다.

 

- 앞에서 구한 각도(θ)만큼 시계 방향으로 회전하여, 여백을 crop한다.

 

- 높이와 폭 중 더 긴 것을 지름으로 선택한다.

 

 

Figure 3. 지름 찾는 방법

 

그 다음 단계는 Ben Graham 방식 전처리로 밝기가 서로 다른 데이터들은 정규화하기 위한 전처리를 진행한다.

 

b. 밝기에 대한 정규화

이는 원본 영상에서 가우시안 블러링 필터로 블리렁한 영상을 빼는 방법이다. 하지만 이때, 그냥 빼는 것이라 서로 값을 4만큼 weight를 주고 뺀다.

기본적으로 원본 영상에서 블러링한 영상을 빼는 행위는 배경을 없애고, edge feature들을 남기는 테크닉이다.

 

c. 필요한 부분 원으로 Crop

Figure 4를 보면 (b)는 원본 영상(a)에서 Ben Graham 방식으로 전처리한 영상이며, (c)는 (b)의 위아래에 보면 살짝 밝은 부분이 존재하는데 이를 제거해주기 위해 살짝 잘라준다.

(d)는 정사각형으로 패딩해주었다.

(e)는 위에서 구한 반지름의 0.95 만큼의 원형 마스크를 생성하였다. 그러한 이유는 (c)에서 원의 가장자리에 여전히 밝은 부분이 남아있는데, 이를 제거해주기 위해 반지름의 0.95로 설정하였다.

(f)는 (e)의 원형 마스크와 (d)의 영상을 곱하면, 마스크의 1인 부분(밝은 부분)만 남기에 필터링이 된다. 그 후 마지막으로 resize를 해준다.

 

이렇게 하면 최소한 데이터 손실로 데이터를 전처리를 할 수 있다.

 

Figure 4. 전처리 후 필요한 부분만 Crop

 

클래스 Preporcessor에는 _preprocessor 멤버 메소드와 classifier 멤버 메소드가 존재한다.

 

_preprocessor는 위의 전처리를 진행하고

classifier resized train 15를 training dataset과 validation dataset으로 분류하는 멤버메소드이다.

class Preprocessor():
    def __init__(self, base_dir = BASE_DIR, height = HEIGHT, width = WIDTH):
        self.base_dir = BASE_DIR
        self.width = width
        self.height = height

    def _preprocessor(self, img, sigmaX = 30):
        # 0. 대각선 길이만큼 상하좌우 패딩
        img_H, img_W = img.shape[0], img.shape[1]
        diagonal = int((img_H**2 + img_W ** 2)**0.5)
        h, w = int((diagonal - img_H)/2), int((diagonal - img_W)/2)
        img = cv2.copyMakeBorder(img, h, h, w, w, cv2.BORDER_CONSTANT,value=0)

        # 1. actan(img_W / img_H)만큼 회전
        img_H, img_W = img.shape[0], img.shape[1]
        degree = math.degrees(math.atan(img_W / img_H))
        x_center, y_center = int(img_H/2), int(img_W/2)
        matrix = cv2.getRotationMatrix2D((x_center, y_center), -degree, 1)
        img = cv2.warpAffine(img, matrix, (img_H, img_W))

        # 2. Crop
        img = cv2.copyMakeBorder(img, 10,10,10,10,cv2.BORDER_CONSTANT,value=[0,0,0])
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        _,gray = cv2.threshold(gray, 10, 255, cv2.THRESH_BINARY)
        contours,hierarchy = cv2.findContours(gray,
                                              cv2.RETR_EXTERNAL,
                                              cv2.CHAIN_APPROX_SIMPLE)
        contours = max(contours, key=cv2.contourArea)
        x,y,w,h = cv2.boundingRect(contours) # 영역이 있는 좌표
        img = img[y:y+h, x:x+w]
        img_H, img_W = img.shape[0], img.shape[1]
        ret = max(img_H, img_W)
        
        # 3. 다시 역회전
        img_H, img_W = img.shape[0], img.shape[1]
        x_center, y_center = int(img_H/2), int(img_W/2)
        matrix = cv2.getRotationMatrix2D((x_center, y_center), degree, 1)
        img = cv2.warpAffine(img, matrix, (img_H, img_W))
        
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        _,gray = cv2.threshold(gray, 10, 255, cv2.THRESH_BINARY)
        contours,hierarchy = cv2.findContours(gray,
                                              cv2.RETR_EXTERNAL,
                                              cv2.CHAIN_APPROX_SIMPLE)
        contours = max(contours, key=cv2.contourArea)
        x,y,w,h = cv2.boundingRect(contours)
        img = img[y:y+h, :]

        # 3. Ben Graham's method
        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
        img = cv2.addWeighted(img, 4, 
                              cv2.GaussianBlur(img, (0,0) , sigmaX) ,-4 ,128)
        img_H, img_W = img.shape[0], img.shape[1]
        img = img[int(img_H * 0.03) : img_H - int(img_H * 0.03),
                  int(img_W * 0.03) : img_W - int(img_W * 0.03)]

        # 4. 원 마스크
        img_H, img_W = img.shape[0], img.shape[1]
        circle_mask = np.zeros([ret, ret, 3], dtype = "uint8")
        cv2.circle(circle_mask, 
                   (int(ret/2), int(ret/2)), 
                   int(ret/2 * 0.95), 
                   (1, 1, 1), 
                   -1)
        
        # 5. 패딩
        # w가 길면 h를 (w-h)/2만큼 위아래 패딩
        # h가 길면 h를 (h-w)/2만큼 위아래 패딩
        if img_H < img_W:
            gap = img_W - img_H
            if gap % 2 == 1:
                top = int(gap/2)
                bottom = top + 1
            else:
                top = int(gap/2)
                bottom = top
        img = cv2.copyMakeBorder(img, top, bottom, 0, 0, cv2.BORDER_CONSTANT,value=0)
                    
        if img_H > img_W:
            gap = img_H - img_W
            if gap % 2 == 1:
                left = int(gap/2)
                right = left + 1
            else:
                left = int(gap/2)
                right = left
        img = cv2.copyMakeBorder(img, 0, 0, left, right, cv2.BORDER_CONSTANT,value=0)
        
        # 6. 원으로 Crop 후 resize
        img = img * circle_mask
        img = cv2.resize(img, (self.width, self.height))
        return img
    def classifier(self):
        file_list = os.listdir(self.base_dir)
        #csv_file = ["test", "train"]
        csv_file = ["test"]
        # 폴더 유무 확인 후 생성
        if "valid_images" not in file_list:
            os.mkdir(self.base_dir+"valid_images")
        else:
            pass
        
        cnt_train_label = [25810*0.80, 2443*0.80, 5292*0.80, 873*0.80, 708*0.80]
        for csv in csv_file:
            data_info = pd.read_csv(self.base_dir + f"{csv}.csv")
            data_info.reset_index(drop=True, inplace = True)
            if csv == "train":
                cnt_label = [0,0,0,0,0]
            for i, file_name in enumerate(data_info["id_code"]):
                label = np.array(data_info.iloc[i, 1], dtype = "int32")
                if csv == "train":
                    file_name = file_name + ".jpg"
                else:
                    file_name = file_name + ".png"
                img = cv2.imread(f"{self.base_dir}{csv}_images/{file_name}")
                try:
                    img = self._preprocessor(img)
                    cv2.imwrite(f"{self.base_dir}{csv}_images/{file_name}", img)
                    if csv == "train":
                        cnt_label[label] += 1
                        # validation dataset
                        if cnt_label[label] > cnt_train_label[label]:
                            shutil.move(f"{self.base_dir}{csv}_images/{file_name}",
                                        f"{self.base_dir}valid_images/{file_name}")
                except:
                    os.remove(f"{self.base_dir}{csv}_images/{file_name}")
                    print(f"{file_name} delete")
        print("완료")

 

아래는 전처리를 개인 PC에서 진행하는 코드이다.

pre = Preprocessor()
pre.classifier()

 

전처리한 데이터를 그대로 두는 것이 아니라, 전처리한 데이터를 모두 한 번씩 훓어봐야 한다.

 

왜냐하면 원본 영상에 렌즈에 지문이 묻어 지문도 같이 찍힌 영상도 있고, 초점이 심각하게 흐린 영상 등등 질이 상당히 떨어지는 영상이 존재하여 일일히 확인 후 제거해주어야한다.(이 과정으로 인해 노가다라고 지칭했다.)

 

이 과정이 끝나면 데이터 용량이 총 4GB가 된다.

 

아래 코드는 위에서 텐서플로우 데이터셋 생성을 위한 코드이다.

class Dataset_Generator():
    def __init__(self, base_dir = BASE_DIR, height = HEIGHT, width = WIDTH):
        self.base_dir = BASE_DIR
        self.width = width
        self.height = height
    
    def _Image_Reshape(self, image, label):
        image = np.reshape(image, ((1,) + image.shape))
        label = np.asarray([[label]], dtype = np.int)
        return (image/255, label)
        
    def train_generator(self):
        file_list = os.listdir(self.base_dir + "train_images/")
        data_info = pd.read_csv(self.base_dir + f"train_dataset.csv")
        data_info.reset_index(drop=True, inplace = True)
        for i, file_name in enumerate(data_info["file_name"]):
            if f"{file_name}.png" in file_list
                label = np.array(data_info.iloc[i, 1], dtype = "int32")
                img = cv2.imread(self.base_dir + f"train_images/{file_name}.jpg")
                yield self._Image_Reshape(img, label)
            
    def valid_generator(self):
        file_list = os.listdir(self.base_dir + "valid_images/")
        data_info = pd.read_csv(self.base_dir + f"train_dataset.csv")
        data_info.reset_index(drop=True, inplace = True)
        for i, file_name in enumerate(data_info["id_code"]):
            if f"{file_name}.png" in file_list
                label = np.array(data_info.iloc[i, 1], dtype = "int32")
                img = cv2.imread(self.base_dir + f"valid_images/{file_name}.jpg")
                yield self._Image_Reshape(img, label)
            
    def test_generator(self):
        data_info = pd.read_csv(self.base_dir + f"test_dataset.csv")
        data_info.reset_index(drop=True, inplace = True)
        
        for i, file_name in enumerate(data_info["id_code"]):
            label = np.array(data_info.iloc[i, 1], dtype = "int32")
            img = cv2.imread(self.base_dir + f"test_images/{file_name}.png")
            yield self._Image_Reshape(img, label)