Dice Score

U-Net 코드화 작업을 통해 image segmentation에서 흔히 사용되는 'dice score'라는 평가 지표와 친숙해지게 되었습니다. 다이스 스코어는 아래와 같은 수식을 통해 계산합니다.

Dice Score는 Precision과 Recall 점수를 나타내는 F1-Score와 비슷합니다. 민감도와 정밀도중에 무엇이 좋고 나쁜지는 까봐야 아는 것도 똑같고요.

도식화하면 위와 같은 그림으로 나타낼 수 있어요. 여기서 X와 Y는 각각 true value와 predicted value라고 생각하면 되겠습니다. Image segmentation에서 Dice score는 predicted value와 true value 간의 유사성을 측정할 수 있는 포괄적인 평가 방식인 거죠. 

예측한 마스크와 실제 마스크가 정확하게 일치하는 경우 dice score는 1의 값을 가지게 되고, 전혀 일치하지 않는 경우 0의 값을 가지게 됩니다. 따라서 계산된 dice score는 범위 [0, 1] 사이의 값을 가지게 되고, 마치 accuracy처럼 1에 가까운 값을 가지게 될수록 모델이 학습을 잘했다고 판단하게 됩니다.


문제 상황

그런데 코드 작업을 하면서, 아래와 같이 dice score가 범위 [0, 1]를 벗어난 값을 가지게 되는 것을 확인하게 되었습니다.

dice score가 -7부터 1.3까지.. 다이나믹 한 값을 가지는군요

https://www.kaggle.com/datasets/faizalkarim/flood-area-segmentation

 

Flood Area Segmentation

Segment the flooded area.

www.kaggle.com

저는 U-Net을 코드 구현하면서 kaggle의 Flood Area Segmentation 데이터를 가지고 작업중이었는데요. Kaggle에서 다른사람들이 쓴 코드는 어떤지 확인을 한 번 해 보았습니다.


다른 사람 코드 1

https://www.kaggle.com/code/gon213/flood-area-by-gontech

같은 Flood Area Segmentation 이미지셋으로 코딩하는 다른사람 코드

Dice score가 0, 1 사이로 제대로 나오고 있군요.

다른 사람 코드 2

https://www.kaggle.com/code/hugolearn/practice-image-segmentation-with-u-net/notebook

Carvana 데이터셋으로 코딩하는 다른사람 코드

요건 저랑 다른 데이터셋 (Carvana dataset) 사용하는 다른사람 코드인데, 보시면 dice score가 음수값이 나옵니다. 더 보면 -5가 나오기도 하고요... 그런데 다들 코드 잘썼다고 칭찬만 하고 이부분을 지적하는 사람이 없더라고요.


문제 해결

Dice score를 정확하게 계산하기 위해서 저는 아래와 같은 방법을 적용해서 문제를 해결했습니다 :) 참고로 제가 다루고 있는 데이터는 '홍수가 난 부분'과 '아닌 부분'으로 binary label을 가집니다.


1. 수식 변경

def dice_score(pred: torch.Tensor, mask: torch.Tensor, threshold: float = 0.5, smooth: float = 1e-6):
    # 시그모이드 적용 후 임계값을 통해 이진화
    pred = torch.sigmoid(pred)
    pred = (pred > threshold).float()
    
    # 마스크가 float 타입인지 확인
    mask = mask.float()
    
    # 교집합과 합집합 계산
    intersection = (pred * mask).sum(dim=[1, 2, 3])
    union = pred.sum(dim=[1, 2, 3]) + mask.sum(dim=[1, 2, 3])
    
    # Dice score 계산
    dice = (2. * intersection + smooth) / (union + smooth)
    
    # 배치의 평균 Dice score 반환
    return dice.mean().item()

처음에는 intersection과 union만 계산했는데, 0.5의 threshold와 smooth값을 추가해서 dice score를 정밀하게 계산했습니다.


2. 데이터 정규화

먼저 가지고 있는 Image와 Mask 데이터의 픽셀값이 0과 1 사이의 수치로 들어와 있는 것을 확인했습니다. 이후 Image data에만 Normalization transform을 적용해주었습니다. (기존에는 정규화를 아예 안했습니다.)

class Mydataset(Dataset):
    def __init__(self, root_dir='flood/', train=True, image_transforms=None, mask_transforms=None):
        super(Mydataset, self).__init__()
        self.train = train
        self.image_transforms = image_transforms
        self.mask_transforms = mask_transforms
        
        # 파일 경로 지정
        file_path = os.path.join(root_dir, 'train')
        file_mask_path = os.path.join(root_dir, 'masked')
        
        # 이미지 리스트 생성
        self.images = sorted([os.path.join(file_path, img) for img in os.listdir(file_path)])
        self.masks = sorted([os.path.join(file_mask_path, mask) for mask in os.listdir(file_mask_path)])
        
        # train, valid 데이터 분리
        split_ratio = int(len(self.images) * 0.8)
        if train: 
            self.images = self.images[:split_ratio]
            self.masks = self.masks[:split_ratio]  # train은 80%
        else:
            self.images = self.images[split_ratio:]
            self.masks = self.masks[split_ratio:]  # valid는 20%
            
    def __getitem__(self, index: int):
        original = Image.open(self.images[index]).convert('RGB') # index 번째의 이미지를 RGB 형식으로 열음
        masked = Image.open(self.masks[index]).convert('L') # 얘는 마스크를 L(grayscale) 형식으로 열음
        
        if self.image_transforms:  # 나중에 image augmentation에 사용됨
            original = self.image_transforms(original)
        if self.mask_transforms:   # 나중에 image augmentation에 사용됨
            masked = self.mask_transforms(masked)
            
        return {'img': original, 'mask': masked} # transform이 적용된 후 텐서를 반환
    
    def __len__(self):
        return len(self.images)  # 이미지의 파일 수를 반환함 -> train = True라면 train 데이터셋의 크기를, False라면 valid 데이터셋의 크기를 반환
            
            
 # ------------------------ data loader 정의하기 ------------------------ #
width = 360
height = 240
batch_size = 4
n_workers = 2

# 이미지만 정규화!
image_transforms = T.Compose([
    T.Resize((width, height)),
    T.ToTensor(),
    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

# 마스크는 정규화 안할거임
mask_transforms = T.Compose([
    T.Resize((width, height)),
    T.ToTensor()
])

train_dataset = Mydataset(root_dir = "flood/", 
                          train = True, # train 용 지정
                          image_transforms = image_transforms, 
                          mask_transforms = mask_transforms)

valid_dataset = Mydataset(root_dir = "flood/", 
                          train = False, # valid 용 지정 
                          image_transforms = image_transforms, 
                          mask_transforms = mask_transforms)

train_dataset_loader = DataLoader(dataset = train_dataset, 
                                  batch_size = batch_size, 
                                  shuffle = True, 
                                  num_workers = n_workers)

valid_dataset_loader = DataLoader(dataset = valid_dataset, 
                                  batch_size = batch_size, 
                                  shuffle = True, 
                                  num_workers = n_workers)

이후 Dice Score 값이 [0, 1] 범위로 잘 계산되는 것을 확인할 수 있었습니다.


감사합니다 :)

https://smartest-suri.tistory.com/49

 

딥러닝 | U-Net(2015) 논문 리뷰

[주의] 본 포스팅은 수리링이 직접 U-Net 논문 원문을 읽고 리뷰한 내용을 담았으며, 참고 문헌이 있는 경우 출처를 명시하였습니다. 본문 내용에 틀린 부분이 있다면 댓글로 말씀해 주시고, 포스

smartest-suri.tistory.com

지난 번 포스팅에서 리뷰한 U-Net 논문을 파이토치를 이용한 코드로 구현한 과정을 정리해 보겠습니다.


1. [연습] Class 없이 한줄씩 구현

직관적인 이해를 위해서 파이토치 코드로 클래스 없이 한줄씩 유넷 구조를 구현해 보도록 하겠습니다. 

# 먼저 필요한 모듈을 임포트 해줍니다.

import torch
import torch.nn as nn
import torchvision.transforms.functional as TF

1-1. Contracting path - 인코딩 파트

  • 논문에서는 valid padding(패딩 없음)을 사용하지만 코드상의 편의를 위해서 모든 콘볼루션 레이어를 same padding(패딩 1)로 구현하겠습니다.
  • 이렇게 할 경우 나중에 skip-connection 파트에서 concatenate할 때 크기가 딱 맞아서 crop할 필요가 없습니다.
input_channels = 3 # 일단 RGB라고 생각하고 3으로 설정하겠습니다.

# 첫번째 블락
conv1 = nn.Conv2d(input_channels, 64, kernel_size = 3, padding = 1)
conv2 = nn.Conv2d(64, 64, kernel_size = 3, padding = 1)
pool1 = nn.MaxPool2d(kernel_size = 2, stride = 2)

# 두번째 블락
conv3 = nn.Conv2d(64, 128, kernel_size = 3, padding = 1)
conv4 = nn.Conv2d(128, 128, kernel_size = 3, padding = 1)
pool2 = nn.MaxPool2d(kernel_size = 2, stride = 2)

# 세번째 블락
conv5 = nn.Conv2d(128, 256, kernel_size = 3, padding = 1)
conv6 = nn.Conv2d(256, 256, kernel_size = 3, padding = 1)
pool3 = nn.MaxPool2d(kernel_size = 2, stride = 2)

# 네번째 블락
conv7 = nn.Conv2d(256, 512, kernel_size = 3, padding = 1)
conv8 = nn.Conv2d(512, 512, kernel_size = 3, padding = 1)
pool4 = nn.MaxPool2d(kernel_size = 2, stride = 2)

1-2. Bottleneck - 연결 파트

bottleneck

  • 연결 구간에 해당하는 Bottleneck 파트를 작성하겠습니다.
  • 여기까지 오면 최종 채널의 수는 1024가 됩니다.
conv9 = nn.Conv2d(512, 1024, kernel_size = 3, padding = 1)
conv10 = nn.Conv2d(1024, 1024, kernel_size = 3, padding = 1)

1-3. Expanding path - 디코딩 파트

  • 디코딩 파트에는 Skip-connection을 통한 사이즈와 필터의 변화에 주목해서 보시면 좋습니다.
# 첫 번째 블락
up1 = nn.ConvTranspose2d(1024, 512, kernel_size = 2, stride = 2)
# 위에 아웃풋은 512지만 나중에 코드에서 skip-connection을 통해 다시 인풋이 1024가 됨
conv11 = nn.Conv2d(1024, 512, kernel_size = 3, padding = 1)
conv12 = nn.Conv2d(512, 512, kernel_size = 3, padding = 1)

# 두 번째 블락
up2 = nn.ConvTranspose2d(512, 256, kernel_size = 2, stride = 2)
conv13 = nn.Conv2d(512, 256, kernel_size = 3, padding = 1) # Skip-connection 포함
conv14 = nn.Conv2d(256, 256, kernel_size = 3, padding = 1)

# 세 번째 블락
up3 = nn.ConvTranspose2d(256, 128, kernel_size = 2, stride = 2)
conv15 = nn.Conv2d(256, 128, kernel_size = 3, padding = 1) # Skip-connection 포함
conv16 = nn.Conv2d(128, 128, kernel_size = 3, padding = 1)

# 네 번째 블락
up4 = nn.ConvTranspose2d(128, 64, kernel_size = 2, stride = 2)
conv17 = nn.Conv2d(128, 64, kernel_size = 3, padding = 1) # Skip-connection 포함
conv18 = nn.Conv2d(64, 64, kernel_size = 3, padding = 1)

# 마지막 아웃풋에서는 1x1 사이즈 콘볼루션을 사용한다고 이야기함.
output = nn.Conv2d(64, 2, kernel_size = 1, padding = 1)

1-4. Forward-pass

포워드 학습 과정을 한줄씩 구현해 보겠습니다.

def unet_forward(x):
    # 인코더 파트
    x = TF.relu(conv1(x))
    x1 = TF.relu(conv2d(x))
    x = pool1(x1)

    x = TF.relu(conv3(x))
    x2 = TF.relu(conv4(x))
    x = pool2(x2)

    x = TF.relu(conv5(x))
    x3 = TF.relu(conv6(x))
    x = pool3(x3)

    x = TF.relu(conv7(x))
    x4 = TF.relu(conv8(x))
    x = pool4(x4)

    # 연결 bottleneck 파트
    x = TF.relu(conv9(x))
    x = TF.relu(conv10(x))

    # 디코더 파트
    x5 = up1(x)
    x = torch.cat([x5, x4], dim = 1) # skip-connection
    x = TF.relu(conv11(x))
    x = TF.relu(conv12(x))

    x6 = up2(x)
    x = torch.cat([x6, x3], dim = 1) # skip-connection
    x = TF.relu(conv13(x))
    x = TF.relu(conv14(x))

    x7 = up3(x)
    x = torch.cat([x7, x2], dim = 1) # skip-connection
    x = TF.relu(conv15(x))
    x = TF.relu(conv16(x))

    x8 = up4(x)
    x = torch.cat([x8, x1], dim = 1) # skip-connection
    x = TF.relu(conv17(x))
    x = TF.relu(conv18(x))

    # 아웃풋 파트
    output = output(x)

    return output

지금까지 클래스 없이 파이토치로 코드를 짜면서 유넷 구조를 직관적으로 이해해 보았습니다.



2. [실전] Class 이용해서 구현

이번엔 파이토치를 사용하는 만큼 클래스를 이용해서 실전 유넷 코드 구현을 해보겠습니다. 먼저 코드를 짜는 과정은 유튜브 [PyTorch Image Segmentation Tutorial with U-NET: everything from scratch baby (Aladdin Persson)]를 참고하여 작성했음을 미리 밝히겠습니다. 

https://youtu.be/IHq1t7NxS8k?si=776huHRjVsIlf_rS


2-1.  모든 블락의 2개 콘볼루션(Conv2d) 레이어를 정의하는 클래스

 

먼저 unet.py 파일을 만들고 클래스들을 작성하여 파일을 모듈화 하겠습니다.

class DoubleConv(mm.Module):
    def __init__(self, in_channels, out_channels):
        super(DoubleConv, self).__init__()
        self.conv = nn.Sequential(
            nn.Conv2d(in_channels, out_channels, kernel_size = 3, stride = 1, padding = 1, bias = False),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace = True),
            nn.Conv2d(out_channels, out_channels, 3, 1, 1, bias = False),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(inplace = True)
        )

    def forward(self, x):
        return self.conv(x)
  • 1장과 마찬가지로 편의를 위해서 valid padding 대신 same padding을 사용합니다. Carvana 대회 1등한 팀도 same padding을 사용했다고 하네요. (저는 다른걸로 할거지만, 영상에서 Carvana 데이터셋을 이용해서 학습을 합니다)
  • Batch Normalization이 추가되었습니다. 논문에서 따로 Batch Normalization을 해준다는 언급은 없었는데요. 영상에 따르면 UNet이 2015년도 발표되었고, BatchNorm은 2016년에 고안된 아이디어라서 그렇다고 합니다. 찾아보니 유넷을 구현하는 많은 코드가 Batch Normalization을 추가하여 Gradient Vanishing/Exploding 문제를 보완하는 것 같습니다.
  • bias를 False로 설정해 주는 이유는 중간에 BatchNorm2d를 추가해주기 때문입니다. bias가 있어봤자 BatchNorm에 의해서 상쇄(cancel)되기 때문에 굳이 bias가 필요 없다고 영상에서 말하고 있습니다. 이부분은 나중에 BatchNorm 논문을 통해 따로 확인해 보겠습니다.

2-2. 유넷 전체 구조를 정의하는 클래스

해당 파트는 코드가 길어지기 때문에 주석을 이용해서 각 부분을 설명했습니다 :) 비교적 간단하지만 그래도 코드를 이해하시려면 유넷 전체 구조에 대한 이해가 필수적입니다.

  • 논문에서는 마지막 아웃풋의 채널이 2였습니다만, 저는 레이블이 0, 1로 binary인 데이터를 다룰 예정이기 때문에 output_channels의 default 값을 1로 두었습니다.
  • features 리스트는 각 블록에서 피처맵의 사이즈를 순서대로 정의하는 역할을 합니다. (contracting path의 경우 순서대로 64, 128, 256, 512 - expanding path의 경우 그 반대)
class UNET(nn.Module):
    def __init__(
            self, in_channels = 3, out_channels = 1, features = [64, 128, 256, 512]
    ):
        super(UNET, self).__init__()
        self.downs = nn.ModuleList() # Contracting path - 인코딩 파트의 모듈을 담을 리스트 선언
        self.ups = nn.ModuleList()   # Expanding path - 디코딩 파트의 모듈을 담을 리스트 선언
        self.pool = nn.MaxPool2d(kernel_size = 2, stride = 2) # 풀링은 모든 블럭에서 공통 사용됨

        # Contracting path (Down - 인코딩 파트) ------------------------
        for feature in features:
            self.downs.append(DoubleConv(in_channels, feature)) # 블록마다 더블콘볼루션 해주고 아웃풋은 feature맵 리스트 순서대로 할당(64, 128...)
            in_channels = feature # 다음 모듈의 인풋 사이즈를 feature로 업데이트

        # Bottleneck (인코딩, 디코딩 연결 파트) ---------------------------
        size = features[-1] # 512
        self.bottleneck = DoubleConv(size, size * 2) # 인풋 512 아웃풋 1024

        # Expanding path (Up - 디코딩 파트) ----------------------------
        for feature in features[::-1]: # 피처맵 사이즈 반대로!
            # 먼저 초록색 화살표에 해당하는 up-conv 레이어를 먼저 추가해 줍니다.
            self.ups.append(
                nn.ConvTranspose2d(
                    feature*2, feature, kernel_size = 2, stride = 2
                    # 인풋에 *2 해주는 이유 : 나중에 skip-connection을 통해서 들어오는 인풋 사이즈가 더블이 되기 때문!
                    # kernel과 stride size가 2인 이유는.. 논문에서 그렇게 하겠다고 했음 '_^ 
                )
            )
            # 이제 더블 콘볼루션 레이어 추가
            self.ups.append(DoubleConv(feature * 2, feature))

        # Output (아웃풋 파트) -----------------------------------------
        last_input = features[0] # 64
        self.final_conv = nn.Conv2d(last_input, out_channels, kernel_size = 1)

    ####################### **-- forward pass --** #######################
    def forward(self, x):
        skip_connections = []

        # Contracting path (Down - 인코딩 파트) ------------------------
        for down in self.downs: # 인코딩 파트를 지나면서 각 블록에서 저장된 마지막 모듈 하나씩 이터레이션
            x = down(x)
            skip_connections.append(x) # skip_connection 리스트에 추가
            x = self.pool(x)

        # Bottleneck (인코딩, 디코딩 연결 파트) ---------------------------
        x = self.bottleneck(x)
        skip_connections = skip_connections[::-1] # 디코딩 파트에서 순서대로 하나씩 뽑기 편하게 리스트 순서 반대로 뒤집어주기

        # Expanding path (Up - 디코딩 파트) ----------------------------
        for i in range(len(skip_connections)):
            x = self.ups[i * 2](x) # self_ups에는 순서대로 ConvTranspose2d와 DoubleConv가 들어가 있음. 
            # 0, 2, 4... 짝수번째에 해당하는 인덱스만 지정하면 순서대로 ConvTranspose2d와(up-conv) 모듈만 지정하게 됨
            skip_connection = skip_connections[i] # skip_connection 순서대로 하나씩 뽑아서
            # concatenate 해서 붙여줄(connection) 차례!
            # 그런데 만약 붙일때 shape이 맞지 않는다면... (특히 이미지의 input_shape이 홀수인 경우 이런 뻑이 나게됨)
            if x.shape != skip_connection.shape:
                x = TF.resize(x, size = skip_connection.shape[2:]) # 간단히resize로 맞춰주겠음
            concat_skip = torch.cat((skip_connection, x), dim = 1) # 이제 붙임!
            x = self.ups[i * 2 + 1](concat_skip)
            # 1, 3, 5... 홀수번째에 해당하는 인덱스만 지정하면 순서대로 DoubleConv 모듈만 지정하게 됨
            
        return self.final_conv(x)

2-3. 테스트

  • 간단한 랜덤 텐서를 생성해서 우리가 구현한 유넷 모델이 제대로 작동하는지 확인하겠습니다.
  • 모델 인풋과 아웃풋의 shape이 정확히 같은지 확인하는 작업이 들어가는데, image segmentation 작업의 특성상 output mask의 크기가 인풋과 동일해야 하기 때문에 그렇습니다.
  • assert 문법을 사용해서 인풋 아웃풋 shape이 다른 경우가 감지되면 AsserstionError를 발생시켜서 Debug에 활용하도록 합니다.
# 유넷 모델 테스트하는 함수 작성

def test():
    # 3장의 1채널(grayscale), width height가 각각 160인 랜덤 텐서 생성 (테스트용으로!)
    x = torch.randn((3, 1, 160, 160))
    # in_channels 1로 설정 (그레이스케일 이미지), out_channels 1로 설정 (binary output)
    model = UNET(in_channels = 1, out_channels = 1)
    # forward pass
    preds = model(x)
    # preds와 x의 shape 확인하기 - 두개가 같아야 함
    print(preds.shape)
    print(x.shape)
    assert preds.shape == x.shape 
        # True : 오케이
        # False : AssertionError

if __name__ == "__main__": # 메인 파일에서만 작동하고 모듈로 import된 경우에는 작동하지 않도록 함
    test()
  
# 실행 결과 ---------------------------
# torch.Size([3, 1, 160, 160])
# torch.Size([3, 1, 160, 160])

구동 결과 shape이 정확히 같아서 test 함수가 제대로 작동한 것을 확인했습니다.

 

여기까지 작성한 코드를 unet.py 파일로 저장해서 모듈화 해주었습니다.

이후 데이터를 로드에 필요한 dataset.py를 작성한 다음 학습에 필요한 utils.py train.py를 차례대로 작성하는 순서로 학습을 진행합니다. 해당 파트는 본 포스팅에서 제외하도록 하겠습니다.

지금까지 유넷 논문에서 살펴본 구조를 파이토치 코드로 구현하는 과정을 포스팅해 보았습니다 :-) 다음 포스팅에서는 트랜스포머 코드화 작업을 수행해 보도록 하겠습니다. 감사합니다!

 

오늘 포스팅에는 유명한 Kaggle 신용카드 사기 감지 데이터셋(Credit Card Fraud Detection)을 가지고 데이터 전처리/분석/머신러닝을 하는 과정을 기록할 것입니다. 데이터 EDA를 진행하고 적절한 전처리를 해준 후 머신러닝 모델링을 수행하고 성능 지표를 비교하는 일련의 과정을 전부 담을 예정인데요, 의식의 흐름대로 작성할 예정이라 중간 중간 Tmi도 많고 삽질하는 내용까지도 필터링 없이 기록할 것임을 미리 알려드립니다.

 

1. 데이터 불러오기, 컬럼/결측치/데이터 타입 확인

https://www.kaggle.com/datasets/mlg-ulb/creditcardfraud/data

 

Credit Card Fraud Detection

Anonymized credit card transactions labeled as fraudulent or genuine

www.kaggle.com

!kaggle datasets download -d mlg-ulb/creditcardfraud
!unzip "/content/creditcardfraud.zip"

먼저 캐글 신용카드 사기 감지 데이터셋을 다운로드받아서 가지고 옵니다. 저는 API Command를 복사하여 실행하고 코랩에 다운받아진 파일을 unzip해주는 형식으로 간단히 데이터를 불러왔습니다. 이렇게 하면 파일을 직접 다운로드해서 가져오는것보다 훨씬 빠릅니다.

 준비된 데이터프레임에 총 31개의 column, 284807개의 row를 확인했습니다.

card.info()

info 메소드를 통해 컬럼의 데이터타입과 결측치 여부를 확인했는데, 다행히 모든 컬럼에 결측치 없이 데이터가 잘 들어가 있었고, 정수타입의 Class 컬럼을 제외한 모든 열은 float64 타입임을 확인했습니다. 컬럼명에 들어있는 V1~V28의 경우 어떤 속성인지 알 수는 없고, Amount는 해당 row의 결제 금액, Class는 정상/사기 이진 분류(binary classification) 결과(label) 컬럼에 해당합니다.

만약 object 타입의 컬럼이 있었다면 라벨 인코딩(Label Encoding)이나 원핫 인코딩(One-Hot Encoding) 등의 작업을 통해 값을 숫자로 변환하는 작업이 필요합니다. 다행히 모두 숫자로 이루어져 있기 때문에 따로 인코딩 작업은 들어가지 않아도 될 것 같습니다 :-)

또, NaN 등의 결측치가 있는 경우 적절한 근거를 가지고 대표값으로 결측치를 채워 넣거나 해당 row를 삭제하는 등의 작업을 통해 결측치를 제거해 주어야 합니다. 다행히도 우리의 친절한 캐글 신용카드 데이터셋은 우리에게 그런 노가다를 요구하지 않고 있습니다... (흡족)

 

2. 결제 금액 분포도 그래프로 확인

결제 금액을 나타내는 Amount 컬럼의 분포도를 그래프로 확인해 보겠습니다.

import seaborn as sns
import matplotlib.pyplot as plt

plt.style.use('fivethirtyeight')
plt.figure(figsize = (10, 4))
plt.xticks(range(0, 40000, 1000), rotation = 45)
sns.histplot(card['Amount'], bins = 100, kde = True)
plt.show()

Tmi지만 저는 matplotlib의 테마 중에서 fivethirtyeight를 가장 좋아합니다. 왜인지는 모르겠습니다. 그래서 웬만하면 그래프를 그릴 때 fivethirtyeight으로 테마를 변경한 다음에 그래프를 뽑는 편입니다. 이런 각박한 작업 속에서도 내 취향이란 걸 반영할 수 있다는 게 전 재밌더라구요^_^...;;;

plt.figure(figsize = (8, 4))
plt.xlim(0, 1000)
plt.xticks(range(0, 1000, 50), rotation = 45)
sns.histplot(card['Amount'], bins = 500, kde = True)
plt.show()

다시 본론으로 돌아와서... 0부터 약 500달러 미만의 결제 금액이 차지하는 비율이 압도적으로 많은 것을 확인할 수 있었습니다. 이 그래프를 확인한 후 저는

과하게 치우친 Amount 컬럼의 값을 로그변환하여 보정해 주면 모델의 성능 지표가 상승할 것이다

라는 첫 번째 가설을 세우게 됩니다.

 

3. Amount 컬럼 박스 플롯 그리기

결제 금액 분포도를 살펴본 이후 저는 Amount 컬럼의 박스 플롯을 그려서 이상치를 확인해 봐야겠다는 생각이 들었는데요.

sns.boxplot(data = card, y = 'Amount')

쩝... 네... 첫 번째 삽질 보여드립니다. 방금 위에서 데이터가 쏠려 있음을 확인해놓고 박스플롯이 예쁘게 그려지리라고 생각한 제가 좀 바보같네요. 결제 금액의 박스 플롯은 큰 의미가 없는 것 같으니 다음으로 데이터 프레임의 상관 계수를 확인해 보도록 하겠습니다.

 

4. 상관 계수 시각화하기

card.corr()

corr() 메소드를 이용해서 모든 컬럼 사이의 상관계수를 나타내어 보았습니다. 한 눈에 들어오질 않으니 일단 쉐입을 확인해 볼게요...

card_corr = card.corr()
card_corr.shape
# (31, 31)

31개의 컬럼 사이의 상관계수가 (31, 31) 정사각 쉐입의 데이터프레임으로 이쁘게 반환되었습니다. 저는 저렇게 e어쩌고로 나타내진 숫자값들은 봐도 봐도 적응이 안되더라구요. 어쨌든 이 상태로는 전체 분포를 한 눈에 알아보기 힘들기 때문에 seaborn의 heatmap을 이용해서 그래프로 시각화를 해 보도록 하겠습니다.

두 번째 Tmi... 저는 또 이렇게 색상이 중요한 그래프를 그릴 때 괜히 컬러 팔레트 고르느라 1-2분을 더 낭비하는 것을 좋아합니다.. 역시 각박한 일상 속에서 찾아내는 저만의 소소한 작은 행복입니다 (^_^*) 이번에는 따뜻한 색감의 노랑-브릭 계열의 팔레트를 골라 보았습니다.

plt.figure(figsize = (8, 8))
sns.heatmap(card_corr, cmap = "YlOrBr")
plt.tight_layout()
plt.savefig("corr.png")

그래프를 큼직하게 뽑아서 괜히 savefig를 이용해 png파일로 다운로드까지 해 보았습니다. 이것도 자주 안하면 계속 까먹어요. ㅋ

상관관계 히트맵에서 양의 상관관계가 높을수록 색깔이 진한 갈색에 가깝고, 음의 상관관계가 높을수록 연한 노란색에 가깝습니다. 그래프의 변두리에 희끗희끗하게 보이는 밝은 부분들이 음의 상관관계가 높은 지점입니다. 

이 때, 레이블에 해당하는 Class컬럼과 가장 낮은 상관관계를 보이는 컬럼은 V14와 V17인것으로 확인되는데요.

이렇게 레이블과 관계 없는 컬럼의 이상치를 제거하면 모델의 성능 지표가 상승할 것이다

라는 두 번째 가설을 세우게 됩니다. 

 

5. [before] baseline 모델링

(5-1) train, test 구분하고 레이블 비율 확인해 보기

card.drop('Time', axis = 1, inplace = True)

먼저 큰 의미가 없는 Time 컬럼은 drop을 통해 삭제를 해 주고 시작하겠습니다.

def train_test(df):
    card_copy = card.copy()
    X_features = card_copy.iloc[:, :-1] # label에 해당하는 마지막 'Class'컬럼 제외
    y_label = card_copy.iloc[:, -1]     # label에 해당하는 마지막 'Class'컬럼만

    from sklearn.model_selection import train_test_split
    X_train, X_test, y_train, y_test = train_test_split(X_features,
                                                        y_label,
                                                        test_size = 0.2,
                                                        random_state = 1004,
                                                        stratify = y_label)
    
    return X_train, X_test, y_train, y_test

X_train, X_test, y_train, y_test = train_test(card)

card 데이터프레임의 마지막 'Class' 컬럼을 기준으로 feature과 label을 구분했구요. 싸이킷런의 train_test_split을 통해 8:2의 비율로 train과 test를 분할했습니다. 3번째 Tmi...인데.. 저는 random_state로 항상 1004(천사)를 사용합니다. ^^;;;;;

len(X_train), len(X_test), len(y_train), len(y_test)
# (227845, 56962, 227845, 56962)

이렇게 나눠졌구요. 그럼 데이터에서 레이블 비율(0과 1의 비율)이 어떻게 되는지 살펴볼까요?

y_train.value_counts(normalize = True).apply(lambda x: str(round(x * 100,4)) + '%')
y_test.value_counts(normalize = True).apply(lambda x: str(round(x * 100,4)) + '%')

와우! 사기에 해당하는 1번 레이블이 약 0.17%에 해당하는 극소수로 확인되었습니다. 하긴, 사기 거래가 2~30%씩 차지하고 있으면 그것도 말이 안되겠네요. 저는 이렇게 뭐든 시각화하면서 가지고 있는 데이터와 친숙해지는 작업이 꼭 필요하다고 생각합니다. 그렇지 않으면 의미 없는 숫자놀음에 그치게 된다고 생각해요,,, 어쨌거나 저쨌거나 이 비율 확인 후 저는 

평가 지표(metrics)로 Accuracy가 아닌 다른 지표들을 전부 다 확인할 필요가 있음

이라는 결론을 내렸고, 다양한 평가 지표를 모두 확인해 보기로 결정했습니다.

(5-2) 모델 학습 후 예측 성능 평가

def train_eval(model, f_tr = None, f_test= None, t_tr= None, t_test= None):
    # 모델 학습
    model.fit(f_tr, t_tr)

    # 예측 
    pred = model.predict(f_test)
    pred_proba = model.predict_proba(f_test)[:, 1]

    # 평가 (1) confusion matrix 출력
    from sklearn.metrics import confusion_matrix
    confusion = confusion_matrix(t_test, pred)
    print("----- confusion matrix -----")
    print(confusion)
    
    # 평가 (2) accuracy / precision / recall / f1 / roc_score 출력
    from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score
    accuracy = accuracy_score(t_test, pred)
    precision = precision_score(t_test, pred)
    recall = recall_score(t_test, pred)
    f1 = f1_score(t_test, pred)
    roc_auc = roc_auc_score(t_test, pred_proba)
    print("----- evaluation score -----")
    print(f"accuracy : {accuracy:.4f}")
    print(f"precision : {precision:.4f}")
    print(f"recall : {recall:.4f}")
    print(f"f1 : {f1:.4f}")
    print(f"ROC_SCORE : {roc_auc:.4f}")

 

먼저 baseline 모델을 학습시키고 예측을 수행한 다음 confusion matrix와 여러 평가 지표를 출력하는 함수를 작성했습니다.

from lightgbm import LGBMClassifier
from xgboost import XGBClassifier

lgbm = LGBMClassifier(n_estimators = 1000,
                      num_leaves = 64,
                      n_jobs = -1,
                      boost_from_average = False)

xgb = XGBClassifier(n_estimators = 1000,
                    learning_rate = 0.05,
                    max_depth = 3, 
                    eval_metric = 'logloss')

이후 XGBM, LightGBM 모델 2가지를 학습하고 예측 평가를 수행했습니다.

train_eval(lgbm, X_train, X_test, y_train, y_test)
train_eval(xgb, X_train, X_test, y_train, y_test)

(좌) LGBM / (우) XGB

결과값은 위와 같이 나왔는데요. Accuracy는 대체로 동일하고, precision과 recall, f1은 LightBGM 모델이 더 높았고, roc_auc 점수는 XGB 모델이 더 높게 측정되었습니다.

 

6. [after] 가설 2가지 적용하기

이 번엔 데이터 분석을 통해 세웠던 2가지의 가설을 검증해보는 작업을 수행해 보겠습니다.

레이블과 관계 없는 컬럼의 이상치를 제거하고 Amount 컬럼의 값을 로그변환하여 보정한다면 모델의 성능 지표를 상승시킬 수 있을 것이다

(6-1) 이상치 찾아내기

def find_outliers(df, column):
    # 해당 컬럼의 사기 데이터 series 선언
    frauds = df[df['Class'] == 1][column]
    
    # IQR 구하기
    import numpy as np
    Q1 = np.percentile(frauds.values, 25)
    Q3 = np.percentile(frauds.values, 75)
    IQR = Q3 - Q1

    # 1QR에 1.5를 곱해서 최댓값, 최솟값 구하기
    lowest_value = Q1 - IQR * 1.5
    highest_value = Q3 + IQR * 1.5

    # 이상치 데이터 찾아서 인덱스만 리스트로 뽑아내기
    outliers = frauds[(frauds < lowest_value) | (frauds > highest_value)].index

    # 이상치 인덱스 리스트 리턴
    return outliers

데이터 프레임과 원하는 컬럼명을 인자로 받아서 해당 컬럼의 이상치 인덱스를 리스트로 반환하는 함수를 작성했습니다 :) 처음부터 바로 함수를 작성한 건 아니고, 컬럼 하나를 지정해서 하나하나 실행해본 다음 깔끔하게 함수로 정리하는 작업을 통해 함수를 완성했습니다.

find_outliers(card, "V14")
# Index([8296, 8615, 9035, 9252], dtype='int64')

find_outliers(card, "V17")
# Index([], dtype='int64')

아까 히트맵을 통해 확인했을 때 Class컬럼과 가장 낮은 상관관계를 보이는 컬럼은 V14와 V17인것으로 확인되었었는데요, find_outliers 함수를 통해 확인해 본 결과 V14 컬럼의 경우 이상치가 4개 발견되었고, V17 컬럼의 경우 이상치가 발견되지 않았습니다 :)

 따라서 저는 V14 컬럼의 이상치 데이터 4개를 삭제해 보겠습니다.

 

(6-2) 이상치 제거, 로그 변환 후 train, test 분리

def train_test_no_outliers(df):
    card_copy = df.copy()
    # outlier 제거
    outliers = find_outliers(card_copy, "V14")
    card_copy.drop(outliers, axis = 0, inplace = True)

    # Amount 컬럼 로그변환
    import numpy as np
    card_copy['Amount'] = np.log1p(card_copy['Amount'])
    
    # 데이터셋 나누기
    X_features = card_copy.iloc[:, :-1] # label에 해당하는 마지막 'Class'컬럼 제외
    y_label = card_copy.iloc[:, -1]     # label에 해당하는 마지막 'Class'컬럼만

    from sklearn.model_selection import train_test_split
    X_train, X_test, y_train, y_test = train_test_split(X_features,
                                                        y_label,
                                                        test_size = 0.2,
                                                        random_state = 1004,
                                                        stratify = y_label)
    
    return X_train, X_test, y_train, y_test

X_train_af, X_test_af, y_train_af, y_test_af = train_test_no_outliers(card)

train, test 분리하는 함수에 Amount 컬럼 로그변환과 outlier rows 제거하는 과정을 추가한 다음 분리 작업을 실시했습니다.

(6-3) 모델 학습 후 예측 성능 평가

train_eval(lgbm, X_train_af, X_test_af, y_train_af, y_test_af)
train_eval(xgb, X_train_af, X_test_af, y_train_af, y_test_af)

(좌) LGBM / (우) XGB

다음과 같이 결과가 나왔습니다.

 

7.  모델 4개 비교

지금까지 학습시킨 모델들의 성능 지표를 표로 작성하여 한눈에 비교해 보겠습니다.

  Baseline(XGB) Baseline(LGBM) After(XGB) After(LGBM)
Accuracy 0.9996 0.9996 0.9996 0.9996
Precision 0.9419 0.9405 0.9405 0.9302
Recall 0.8265 0.8061 0.8061 0.8163
F1 0.8804 0.8681 0.8681 0.8696
ROC Score 0.9853 0.9863 0.9882 0.9793

저는 이상치 제거와 로그 변환의 전처리 과정을 거친 다음 학습한 After 모델들의 모든 성능 지표가 다 개선될 것이라고 생각했습니다. 하지만 실습 결과 Precision / Recall / F1의 경우 오히려 Baseline의 수치가 더 높게 나온것을 확인할 수 있었습니다. 역시 인생이란 건 그렇게 호락호락하지 않습니다. 원하는 대로 결과가 안 나와서 좀 찝찝하긴 한데요...

다만, After XGB 모델 ROC-AUC 스코어의 경우 Baseline 모델들보다 점수가 더 상승한 것을 확인할 수 있었습니다. 이번에 다룬 Kaggle 신용카드 사기감지 데이터셋의 경우, 기존에 미리 확인한 대로 전체 데이터의 약 0.17%만이 사기에 해당하는 극도로 치우친 값을 가지고 있었습니다. 이렇게 과하게 편향된 데이터셋의 경우 ROC-AUC 스코어가 유의미하므로, 만약 4가지 모델 중 한 가지를 골라야 한다면 저는 ROC-AUC 스코어가 가장 높게 나온 After(XGB) 모델을 고르는 것도 나쁘지 않겠다는 생각을 했습니다.

 

8. 마무리

이번 포스팅은 여기에서 실습을 마치도록 할텐데요. 위의 4가지 모델에서 성능 지표를 더 올리기 위해서는 0.17%에 해당하는 사기 데이터를 펌핑해서 수를 늘려 주는 방법을 사용하면 좋을 것 같습니다. 마음같아서는 지금 당장 하고싶지만 할일이 많아서 일단 여기에서 끊겠습니다만, 좀 찝찝하기 때문에 시간여유가 생기면 모델 개선 작업을 추가로 수행해서 추가 포스팅을 하도록 하겠습니다.

이번 실습을 통해서 앞으로 다가올 머신러닝 팀프로젝트에 어떤 식으로 데이터 EDA를 진행하고 모델링을 개선해나갈 수 있을 지 좋은 가이드라인이 되었습니다. 

읽어주셔서 감사합니다 :-)

 

+ Recent posts