포스트

백혈구 4종 분류, 정확도만 보면 위험한 이유: Keras 모델과 CAM 점검

이 예제에서 가장 중요한 것은 높은 accuracy가 아니라 학습, 검증, 최종 테스트를 섞지 않고, 폴더의 클래스 순서를 확인한 뒤, 모델이 세포가 아닌 배경을 보고 있지 않은지 CAM으로 점검하는 것이다.

의료 이미지에서는 같은 원본에서 만든 유사한 crop이 서로 다른 split에 들어가지 않았는지 특히 확인해야 한다. 이 글은 학습 예제를 이해하기 위한 기록이며, 여기서 얻은 수치를 실제 진단 성능으로 확대하지 않는다.

데이터와 문제 정의를 어떻게 확인할까?

이 글의 데이터는 백혈구를 네 종류로 분류한다.

  • NEUTROPHIL: 호중구
  • EOSINOPHIL: 호산구
  • MONOCYTE: 단핵구
  • LYMPHOCYTE: 림프구

분류는 이미지 한 장에 하나의 이름을 붙이는 문제다. 세포의 위치까지 찾으려면 별도의 detection 데이터와 평가 방식이 필요하므로 둘을 혼동하면 안 된다.

폴더를 읽기 전에 클래스 번호부터 고정하기

원문 실험은 Colab과 Keras를 사용했다. 폴더 구조를 곧바로 loader에 넘기기 전에 실제 클래스 이름과 샘플을 눈으로 확인한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import os
import cv2
import matplotlib.pyplot as plt

image_root = './dataset2-master/dataset2-master/images'
train_path = os.path.join(image_root, 'TRAIN')
test_path = os.path.join(image_root, 'TEST')

classes = sorted(os.listdir(train_path))
print('classes:', classes)

for index, class_name in enumerate(classes):
    class_path = os.path.join(train_path, class_name)
    sample_path = os.path.join(class_path, os.listdir(class_path)[0])
    image = cv2.cvtColor(cv2.imread(sample_path), cv2.COLOR_BGR2RGB)
    plt.subplot(1, len(classes), index + 1)
    plt.title(class_name)
    plt.axis('off')
    plt.imshow(image)

blood_figure1

flow_from_directory가 만든 번호는 폴더 이름에서 결정된다. 뒤에서 2를 MONOCYTE라고 가정하기 전에 반드시 train_data.class_indices를 출력해야 한다. 클래스 번호를 잘못 잡으면 예측도 CAM도 다른 클래스를 설명한다.

작은 CNN을 구성하는 핵심 조각

원문 모델은 SqueezeNet의 fire module과 닮은 구조다. 1×1 convolution으로 채널을 줄인 뒤, 1×1과 3×3 두 경로를 합친다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
bn_momentum = 0.85

def fire(x, squeeze, expand):
    y = Conv2D(squeeze, 1, activation='relu', padding='same')(x)
    y = BatchNormalization(momentum=bn_momentum)(y)

    branch_1 = Conv2D(
        expand // 2, 1, activation='relu', padding='same'
    )(y)
    branch_1 = BatchNormalization(momentum=bn_momentum)(branch_1)

    branch_3 = Conv2D(
        expand // 2, 3, activation='relu', padding='same'
    )(y)
    branch_3 = BatchNormalization(momentum=bn_momentum)(branch_3)
    return concatenate([branch_1, branch_3])

입력 크기는 128×128×3, 마지막 출력은 네 클래스 softmax다. 아래는 전체 학습 스크립트가 아니라 층의 흐름을 보여주는 핵심 조각이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
inputs = Input(shape=(128, 128, 3))
x = Conv2D(12, 5, padding='same', activation='relu')(inputs)
x = BatchNormalization(momentum=bn_momentum)(x)

x = fire(x, 12, 24)
x = MaxPooling2D(2)(x)
x = fire(x, 24, 48)
x = MaxPooling2D(2)(x)
x = fire(x, 32, 64)
x = MaxPooling2D(2)(x)
x = fire(x, 24, 48)
x = MaxPooling2D(2)(x)
x = fire(x, 18, 36)
x = MaxPooling2D(2)(x)
x = fire(x, 12, 24)

x = GlobalAveragePooling2D()(x)
outputs = Dense(4, activation='softmax')(x)
model = Model(inputs, outputs)

전처리에서는 train과 test 모두 같은 1/255 스케일을 적용했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
train_generator = ImageDataGenerator(rescale=1.0 / 255)
test_generator = ImageDataGenerator(rescale=1.0 / 255)

train_data = train_generator.flow_from_directory(
    train_path,
    target_size=(128, 128),
    batch_size=64,
    class_mode='categorical',
    shuffle=True,
)
test_data = test_generator.flow_from_directory(
    test_path,
    target_size=(128, 128),
    batch_size=64,
    class_mode='categorical',
    shuffle=False,
)

print(train_data.class_indices)

최종 평가 loader는 shuffle=False가 결과와 파일을 대조하기 쉽다. 원문 코드처럼 test 폴더를 학습 중 validation_data로 계속 보면, 그 결과는 더 이상 손대지 않은 최종 시험이라고 보기 어렵다. 가능하면 train에서 validation을 따로 떼고 test는 마지막 한 번의 평가에 남겨둔다.

학습 곡선에서 먼저 볼 것

원문은 Adam, categorical cross entropy, 10 epoch마다 학습률을 절반으로 낮추는 scheduler를 사용했다. 다음 함수는 그 규칙만 보여준다.

1
2
3
4
5
6
7
8
9
def step_decay(epoch):
    initial_rate = 0.1
    drop = 0.5
    epochs_drop = 10.0
    return initial_rate * math.pow(
        drop, math.floor((1 + epoch) / epochs_drop)
    )

lr_scheduler = LearningRateScheduler(step_decay)

blood_figure2

그래프에서는 train accuracy 하나만 보지 말고 train/validation loss가 벌어지는 시점을 함께 본다. 데이터 수, 클래스별 개수, 중복 이미지 여부를 확인하지 않은 채 “loss가 작고 accuracy가 높다”고 결론 내리면 모델보다 데이터 분할을 평가했을 가능성을 놓친다.

또한 이 코드는 작성 당시 Keras API를 사용한 실험 기록이다. fit_generator, evaluate_generator, optimizer의 인자 등은 설치한 버전에서 그대로 동작하지 않을 수 있으므로, 현재 환경에 맞는 호출법을 확인해야 한다.

CAM으로 세포가 아닌 배경을 보는지 확인하기

GAP 바로 앞 특징 맵과 마지막 Dense 가중치를 결합하면 클래스별 CAM을 만들 수 있다. 원문에서는 concatenate_6이 마지막 특징 층이었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class_weights = model.layers[-1].get_weights()[0]
layer_by_name = {layer.name: layer for layer in model.layers}
final_conv = layer_by_name['concatenate_6']

get_output = K.function(
    [model.layers[0].input],
    [final_conv.output, model.layers[-1].output],
)

conv_outputs, predictions = get_output([[image / 255.0]])
conv_outputs = np.transpose(
    np.float32(conv_outputs[0]), (2, 0, 1)
)

target_index = train_data.class_indices['MONOCYTE']
cam = np.zeros(conv_outputs.shape[1:3], dtype=np.float32)
for channel, weight in enumerate(class_weights[:, target_index]):
    cam += weight * conv_outputs[channel]

이 역시 모델과 전처리가 준비됐다는 전제의 핵심 조각이다. layer 이름은 모델을 다시 만들 때 달라질 수 있으므로 하드코딩 전에 확인한다.

blood_figure3

blood_figure4

CAM이 세포 중심이 아니라 모서리, 촬영 흔적, 특정 색 배경만 강조한다면 높은 분류 점수도 신뢰하기 어렵다. 반대로 세포 일부가 밝다고 해서 의학적으로 타당한 근거가 증명되는 것도 아니다. 이 시각화는 진단 근거가 아니라 데이터 누수와 지름길 학습을 의심하게 해주는 디버깅 도구로 사용하는 편이 안전하다.

blood_figure6

CAM 원리는 별도 글, GAP가 없는 구조까지 확장하는 방법은 Grad-CAM 글에서 이어서 볼 수 있다.

데이터 분리를 어떻게 검증해야 하나

먼저 class별 파일 수를 split마다 센다. 전체 accuracy가 큰 class에 의해 좌우되지 않도록 각 class의 비율과 누락 여부를 확인한다. 파일명 규칙에 원본이나 환자 식별 단서가 있다면 같은 원본에서 나온 이미지가 training과 test에 나뉘지 않았는지 검사한다.

Generator를 만든 직후 class_indices를 저장한다. Class 이름과 index, 모델 출력의 열 순서, confusion matrix label이 같은 mapping을 사용해야 한다. 순서가 어긋나면 모델 prediction은 맞아도 보고서의 세포 이름이 바뀔 수 있다.

전처리한 batch를 class별로 직접 본다. Resize로 세포가 찌그러졌는지, normalization 범위가 training과 test에서 같은지, augmentation이 세포 구조를 훼손하지 않는지 확인한다. Loader가 성공했다는 사실은 이미지 의미가 보존됐다는 뜻이 아니다.

Accuracy 대신 어떤 결과를 함께 볼까

Confusion matrix로 어느 두 종류가 반복해서 섞이는지 본다. Class별 precision, recall과 실제 오분류 이미지를 함께 저장하면 모델 구조 문제인지 라벨, 화질 문제인지 다음 점검 대상을 고를 수 있다. Threshold가 필요한 문제라면 단일 argmax 결과와도 구분한다.

학습 곡선에서 training과 validation의 loss, accuracy 간격을 본다. Training만 계속 좋아지고 validation이 멈추면 epoch를 늘리는 것이 답이 아닐 수 있다. 가장 좋은 시점의 checkpoint를 독립 test에 한 번 적용하고 그 결과를 반복 튜닝에 다시 사용하지 않는다.

CAM은 정답과 오답 class를 함께 만든다. 세포 중심이 아니라 테두리, 배경색, 촬영 자국이 반복되면 해당 영역을 가리거나 다른 배경의 데이터를 비교한다. 밝은 영역을 곧바로 세포학적 근거라고 부르지 않는다.

이 예제 결과를 사용할 수 없는 경우

독립된 환자 단위 test가 없거나 class mapping을 확인하지 않았다면 실제 일반화를 주장할 수 없다. Kaggle 폴더에서 얻은 수치가 다른 현미경, 염색, 촬영 환경에 유지된다는 보장도 없다. 데이터 출처와 split 단위를 결과와 함께 명시한다.

CAM이 그럴듯해도 예측 오류와 calibration, 품질이 낮은 입력의 거부 정책이 검증되지 않았다. 이 예제는 코드와 데이터 진단 흐름을 배우는 범위이며 의료 판단에 직접 사용해서는 안 된다.

오류 분석은 confusion matrix의 큰 칸부터 실제 이미지를 다시 여는 방식이 유용합니다. 특정 두 세포 유형만 자주 섞이면 모델 용량을 키우기 전에 그 둘을 가르는 형태가 현재 crop과 해상도에 남아 있는지 확인합니다. 배경색이나 촬영 장비별 차이로 예측이 갈리면 환자나 촬영 단위 분할이 제대로 되었는지, train과 validation의 전처리가 같은지도 되짚어야 합니다.

CAM과 분류 확률도 한 표에서 봅니다. 높은 확률로 맞혔지만 배경만 강조한 표본, 낮은 확률이지만 세포를 본 표본, 틀리면서 테두리를 본 표본을 나누면 다음 조치가 달라집니다. 전자는 shortcut 가능성을 조사하고, 후자는 클래스 간 모호성과 라벨을 검토합니다. 설명 그림이 그럴듯하다는 이유로 의학적 근거가 확보됐다고 결론 내리면 안 됩니다.

함께 읽으면 이해가 이어지는 글

자주 묻는 질문

백혈구 분류에서 validation accuracy만 보면 왜 위험한가요?

Class 불균형과 split 누수, 같은 촬영 조건의 반복으로 전체 수치가 높아질 수 있습니다. Class별 confusion과 독립 test, 원본 단위 분리를 함께 확인해야 합니다.

폴더 이름이 같으면 class index도 항상 같나요?

데이터 loader가 정한 정렬 순서에 따라 index가 달라질 수 있습니다. Training, validation, test generator의 class_indices를 출력해 같은 순서인지 확인해야 합니다.

CAM이 세포를 강조하면 모델이 의학적으로 타당한가요?

그렇게 증명되지는 않습니다. CAM은 모델이 사용한 공간 단서를 점검하는 도구이며, 진단 근거나 임상적 타당성을 대신하지 않습니다.

THE END / OPSOAI

여기까지 읽었습니다

핵심 장면을 한 번 더 떠올려 보세요. 이해가 남았다면 이 책은 제 역할을 다했습니다.

다른 책 고르기
표지 1

키와 좌우 스와이프를 지원합니다. 읽던 페이지는 이 기기에 저장됩니다.

CONTENTS

이 책의 목차

    10개 장 16 분읽는 시간