Deep Learning
#
2026-02-27 ⋯ ResNet 망막증 분류 #5 평가 지표와 전체 파이프라인
모델이 학습을 마쳤다. 512×512 망막 사진을 넣으면 5개 등급에 대한 확률을 내놓는다. 이제 가장 중요한 질문이 남았다. 이 모델이 실제로 쓸 만한가? 이걸 판단하려면 적절한 평가 지표가 필요하다. 이 프로젝트에서는 세 가지 평가 도구를 쓴다. 단순 정확도, 이차 가중 카파, 그리고 혼동행렬이다. 각각이 모델의 다른 측면을 보여주는데, 특히 두 번째 지표가 이 문제의 핵심을 찌른다. 단순 정확도(DRAccuracy) 가장 직관적인 지표로 전체 이미지 중 몇 퍼센트를 정확히 맞혔는가이다. 모델의 예측 확률 분포에서 가장 높은 확률의 등급을 뽑고, 정답과 비교해서 일치하면 맞은 것, 아니면 틀린 것으로 센다. 코드를 보면 np.argmax(y, 1)과 np.argmax(y_pred, 1)이 나온다. argmax는 배열에서 가장 큰 값의 위치(인덱스)를 돌려주는 함수다. 정답이 원-핫 벡터 [0, 0, 1, 0, 0]이면 argmax는 2를 돌려준다. 예측이 [0.06, 0.68, 0.12, 0.03, 0.11]이면 argmax는 1을 돌려준다. 정답 2, 예측 1이니까 이건 틀린 것이다. 단순하고 이해하기 쉽지만, 앞서 이야기했듯이 이 문제에서는 심각한 한계가 있는데 정확도는 등급 0을 등급 1로 틀린 것과 등급 0을 등급 4로 틀린 것을 동일하게 취급한다. 한 단계 차이든 네 단계 차이든 그냥 "하나 틀림"이다. 의학적으로 이 두 실수의 무게는 완전히 다른데도 말이다. 게다가 데이터가 불균형하기 때문에, 모든 이미지에 "등급 0"이라고 찍어버리는 멍청한 모델도 정확도가 70%를 넘을 수 있다. 그래서 두 번째 지표가 필요하다. 이차 가중 카파(Quadratic Weighted Kappa) 이차 가중 카파는 "틀린 정도"에 비례해서 벌점을 매기되, 랜덤하게 찍는 것보다 얼마나 나은지를 측정하는 것이다. 첫 번째 단계는 혼동행렬(confusion matrix)을 만드는 것이다. 5×5 크기의 표인데, 행이 실제 등급이고 열이 예측 등급이다. cm[2][3]의 값이 60이면 "실제 등급 2인 이미지를 등급 3으로 잘못 예측한 게 60개"라는 뜻이다. 대각선 위의 값은 정확히 맞춘 것이고, 대각선에서 벗어난 값은 틀린 것이다. 대각선에서 멀리 벗어날수록 더 크게 틀린 것이다. 두 번째 단계는 기대 혼동행렬(E)을 만드는 것이다. 이건 "모델이 아무 생각 없이 랜덤하게 예측했다면 어떤 혼동행렬이 나왔을까?"를 계산한 것이다. 계산 방식은 각 칸에 해당하는 행의 실제 빈도와 열의 예측 빈도를 곱하는 것이다. 예를 들어 실제 등급 0이 25,000개이고 모델이 등급 1로 예측한 게 총 3,000개라면, 랜덤 상황에서 실제 0이 예측 1에 걸릴 기대값은 이 둘의 곱에 비례한다. 이 기대 혼동행렬은 "아무런 실력 없이 찍기만 한 분류기의 기준선"을 제공한다. 세 번째 단계가 이 지표의 핵심인데, 가중치 행렬(w)을 만드는 것이다. w[i][j] = (i-j)² / (5-1)²이다. 이걸 전부 계산하면 5×5 표가 나오는데, 대각선은 전부 0이다. 정확히 맞추면 벌점이 없다는 뜻이다. 대각선에서 한 칸 벗어나면 0.0625, 두 칸이면 0.25, 세 칸이면 0.5625, 네 칸(최대)이면 1.0이다.
여기서 "이차(Quadratic)"라는 이름의 의미가 드러난다. 벌점이 거리의 제곱에 비례한다. 한 단계 차이의 벌점이 0.0625인데, 두 단계 차이는 0.25로 4배다. 세 단계 차이는 0.5625으로 9배다. 네 단계 차이는 1.0으로 16배다. 거리가 두 배면 벌점이 네 배가 되는 것이다. 이 제곱 관계 때문에 큰 오류에 불균형적으로 큰 페널티가 부과된다. 이것이 의학적으로 정확히 맞는 설계다. 등급 0(정상)을 등급 4(증식성)로 오진하는 것은 등급 0을 등급 1(경증)로 오진하는 것보다 16배 더 심각한 실수로 취급되어야 한다. 단순 정확도로는 이 차이를 전혀 반영할 수 없지만, 이차 가중 카파는 정확히 이 차이를 포착한다. 네 번째 단계에서 최종 카파 값을 계산한다. 실제 혼동행렬(cm)에 가중치(w)를 원소별로 곱해서 합산하면 "모델의 가중 오류 총합"이 나온다. 기대 혼동행렬(E)에 같은 가중치를 곱해서 합산하면 "랜덤 분류기의 가중 오류 총합"이 나온다. 카파는 1 - (모델의 가중 오류) / (랜덤의 가중 오류)다. 이 공식의 의미를 풀어보면 이렇다. 모델의 가중 오류가 랜덤 분류기의 가중 오류와 같으면, 분수가 1이 되어 카파는 0이다. 모델이 랜덤하게 찍는 것과 다를 바 없다는 뜻이다. 모델의 가중 오류가 0이면(완벽한 예측) 분수가 0이 되어 카파는 1이다. 최고 점수다. 모델이 랜덤보다도 더 체계적으로 틀리면 카파가 음수가 될 수도 있다. 이건 모델이 차라리 주사위를 굴리는 것만도 못하다는 비참한 결과다. 혼동행렬 자체는 지표라기보다 진단 도구에 가깝다. 5×5 표를 직접 들여다보면 모델이 어디서 잘하고 어디서 못하는지가 한눈에 보인다.
예를 들어 혼동행렬에서 실제 등급 3인 행을 보면 [10, 20, 70, 150, 20]이라고 하자. 정확히 맞춘 게 150개, 등급 2로 틀린 게 70개, 등급 1로 틀린 게 20개다. 이걸 보면 "모델이 등급 3과 등급 2를 잘 구분하지 못하는구나"라는 구체적인 약점을 파악할 수 있다. 정확도나 카파 같은 단일 숫자만으로는 이런 세밀한 정보를 얻을 수 없다. 의료 현장에서는 이 혼동행렬이 특히 중요하다. 등급 4를 등급 0으로 오분류하는 건(심각한 환자를 정상이라고 보내는 건) 절대 있어서는 안 되는 일이다. 혼동행렬을 보면 이런 위험한 오분류가 실제로 얼마나 발생하는지를 직접 확인할 수 있다. cf 혼동행렬과 기대혼동행렬 예시 총 1,000장의 이미지가 있고, 실제 분포가 이렇다고 가정. 혼동행렬 (Confusion Matrix, cm)은 모델이 실제로 예측한 결과를 집계한 것. 행이 실제 등급, 열이 예측 등급. 대각선이 정답이다. 등급 0은 420/500 = 84%를 맞혔으니 꽤 잘한다. 등급 3은 35/80 = 44%밖에 못 맞히고 있다. 등급 4도 35/70 = 50%다. 희귀한 중증 등급에서 성능이 확 떨어지는 전형적인 패턴이다. 혼동행렬에서 대각선 밖의 숫자를 읽는 법도 알아두자. 실제 등급 2 행에서 예측 3 열의 값이 25다. 이건 "실제로 중등도(2)인 이미지 25장을 중증(3)으로 과대 진단했다"는 뜻이다. 실제 등급 4 행에서 예측 0 열의 값이 3이다. "증식성 망막증(4) 환자 3명을 정상(0)이라고 돌려보냈다"는 뜻이니 의학적으로 가장 위험한 오분류다. 기대 혼동행렬은 "모델이 아무런 실력 없이 랜덤하게 예측했다면 나왔을 혼동행렬". 계산 방식은 아래와 같다 계산하며 아래와 같이 나옴. E[0,0] = 236.5는 "실제 등급 0이 500장이고 모델이 등급 0으로 예측한 게 총 473장이면, 아무 연관 없이 랜덤하게 매칭했을 때 둘이 우연히 겹치는 수가 약 237장"이라는 뜻이다. 실제 혼동행렬의 cm[0,0] = 420은 이보다 훨씬 높으니, 모델이 등급 0에 대해서는 랜덤보다 확실히 잘하고 있는 것이다. 반면 E[4,0] = 33.11인데 cm[4,0] = 3이다. 랜덤하게 찍었으면 33장이나 실제 4를 예측 0으로 틀렸을 텐데, 실제 모델은 3장밖에 안 틀렸다. 이 위험한 오분류에서도 모델이 랜덤보다 훨씬 낫다는 의미다. 카파를 계산할 때 이 세 행렬이 이렇게 결합된다. 카파 계산의 핵심은 w × cm의 각 원소를 다 더한 것(모델의 가중 오류 총합)과 w × E의 각 원소를 다 더한 것(랜덤의 가중 오류 총합)을 비교하는 것이다. 예를 들어 cm[4,0] = 3에 w[4,0] = 1.0이 곱해지면 3.0이 된다. 가장 심각한 오류(증식성을 정상으로)이니 가중치가 최대다. cm[0,1] = 50에 w[0,1] = 0.0625가 곱해지면 3.125가 된다. 50건이나 틀렸지만 한 단계 차이라 가중치가 작아서 벌점이 비슷하다. 이렇게 모든 칸을 계산해서 합산하면 모델의 가중 오류 총합이 나오고, 같은 방식으로 E에 대해 계산하면 랜덤의 가중 오류 총합이 나온다. κ = 1 - (모델의 가중 오류) / (랜덤의 가중 오류)이므로, 모델이 랜덤보다 가중 오류를 많이 줄일수록 카파가 1에 가까워진다. 전체 파이프라인 실행 load_images_DR로 데이터를 훈련, 검증, 테스트 세 세트로 나눈다. split='random'이고 seed=123인데, 랜덤하게 나누되 시드를 고정해서 실험을 재현할 수 있게 한다. 같은 시드를 쓰면 같은 분할이 나오므로, 나중에 다른 사람이 동일한 실험을 반복할 수 있다. 모델을 만들 때 learning_rate=1e-5, 즉 0.00001이라는 매우 작은 학습률을 쓴다. 학습률은 한 번의 가중치 업데이트에서 얼마나 크게 바꿀지를 결정하는데, 의료 이미지 분류에서는 작은 학습률이 중요하다. 망막의 출혈 반점은 몇 픽셀 차이로 보이고 안 보이고가 갈리는 미세한 특징이다. 학습률이 크면 가중치가 한 번에 너무 많이 바뀌면서 이런 미세한 패턴을 잡았다가 다음 업데이트에서 놓쳐버리는 진동이 생길 수 있다. 작은 학습률로 조심스럽게 한 발짝씩 움직이는 게 안전하다. 사전학습 모델을 불러오는 부분은 84,384번의 학습 스텝을 이미 거친 모델의 가중치를 다운로드해서 이어서 학습하는 것이다. 수만 장의 고해상도 이미지를 처음부터 학습하려면 며칠이 걸릴 수 있으니, 이미 상당 부분 학습된 모델에서 시작하면 시간을 크게 절약할 수 있다. checkpoint_interval=1000은 1,000 스텝마다 모델 상태를 저장하라는 뜻인데, 학습 중간에 서버가 꺼지거나 문제가 생겨도 마지막 체크포인트에서 이어서 할 수 있게 하는 안전장치다. 평가는 세 단계로 이루어진다. 먼저 훈련 세트에서 평가한다. 이건 모델이 본 데이터에서의 성능이니까 당연히 높아야 한다. 만약 훈련 세트 성능도 낮다면 모델의 용량이 부족하거나 학습이 덜 된 것이다. 그 다음 검증 세트에서 평가한다. 검증 세트는 학습에 쓰이지 않은 데이터인데, 하이퍼파라미터(학습률, 필터 수, 레이어 수 등)를 조정할 때 기준이 된다. 훈련 성능은 높은데 검증 성능이 낮으면 과적합이 진행 중이라는 신호다. 마지막으로 테스트 세트에서 평가한다. 테스트 세트는 모든 결정이 끝난 후에 딱 한 번 성능을 재는 데 쓴다. 이것이 "이 모델의 진짜 실력"을 가장 공정하게 보여주는 숫자다. 검증 세트와 테스트 세트에서는 혼동행렬도 함께 출력한다. 숫자 하나(정확도, 카파)만으로는 모델의 구체적인 행동 패턴을 알 수 없으니, 혼동행렬로 "어떤 등급 쌍에서 오분류가 집중되는가"를 확인해서 모델의 약점을 진단하는 것이다. 정리 이 프로젝트의 평가 체계는 세 가지 도구가 서로 다른 깊이의 정보를 제공하도록 설계되어 있다. 단순 정확도는 전체적인 맞춤 비율이라는 가장 기본적인 스냅샷을 준다. 이차 가중 카파는 오류의 심각도를 반영한 종합 점수로, 이 문제의 순서형 특성을 정확히 포착한다. 혼동행렬은 모델이 어디서 잘하고 어디서 실패하는지의 상세한 지도를 펼쳐 보여준다. 이 세 도구를 함께 봐야 모델의 성능을 입체적으로 이해할 수 있다.
2026-02-27 ⋯ ResNet 망막증 분류 #4 분류모델 학습
아키텍처 개요 이전 단계에서 512×512 크기의 깔끔한 망막 이미지와 등급 레이블, 그리고 클래스 가중치가 준비되었다. 이제 이 이미지를 받아서 "이 망막은 등급 몇이다"라고 판정하는 모델을 만들 차례다. 이전 챕터에서 DNA 서열을 분석할 때는 Conv1D를 썼다. DNA는 1차원이니까. 이미지는 2차원(가로×세로)이므로 Conv2D를 쓴다. 하지만 단순히 Conv2D를 몇 겹 쌓는 것만으로는 부족하다. 512×512짜리 고해상도 이미지에서 미세한 출혈 반점부터 전체적인 혈관 구조까지 다양한 스케일의 특징을 잡아내려면 아주 깊은 네트워크가 필요하다. 그런데 네트워크가 깊어지면 학습이 잘 안 되는 근본적인 문제가 생긴다. 이 문제를 해결하기 위해 ResNet이라는 구조가 등장한다. 전체 흐름을 먼저 그려보면 이렇다. 이미지가 들어오면 DRAugment가 증강과 정규화를 하고, 첫 번째 Conv 블록이 기본 특징을 추출하고, 다섯 개의 잔차 블록이 점점 더 추상적인 특징을 학습하면서 동시에 이미지를 축소해나가고, 글로벌 최대 풀링이 공간 정보를 압축하고, 완전 연결 레이어가 최종 판단을 내리고, Softmax가 5개 등급에 대한 확률을 출력한다. 첫 번째 Conv 블록 모델의 첫 관문은 Conv2D(32, kernel_size=7)이다. 7×7 크기의 필터 32개가 이미지 위를 훑으면서 가장 기본적인 시각 특징을 잡아낸다. 엣지, 색 경계, 밝기 변화 같은 것들이다. DNA 분석에서 Conv1D의 kernel_size=10이 10개 염기의 모티프를 잡았듯이, 여기서 7×7 필터는 7×7 픽셀 범위의 시각 패턴을 잡는다. kernel_size가 7로 꽤 큰 이유는 첫 번째 레이어이기 때문이다. 원본 이미지의 픽셀 수준에서는 의미 있는 패턴이 꽤 넓은 범위에 걸쳐 있을 수 있다. 이후 레이어에서는 이미지가 축소되어 있으므로 3×3 같은 작은 필터로도 충분하다. Conv2D 다음에 BatchNormalization이 온다. 신경망의 각 레이어는 이전 레이어의 출력을 입력으로 받는다. 그런데 학습이 진행되면서 이전 레이어의 가중치가 바뀌면, 그 출력의 분포도 함께 변한다. 어떤 에포크에서는 출력값이 주로 0-10 범위였는데, 다음 에포크에서는 -5-5 범위로 바뀌는 식이다. 다음 레이어 입장에서는 입력의 분포가 매번 변하니까 적응하기가 어렵다. 마치 과녁이 계속 움직이는 사격 훈련과 같다. BatchNormalization은 각 배치의 출력값을 평균 0, 분산 1로 정규화해서 분포를 안정시킨다. 과녁을 제자리에 고정하는 셈이다. 그런 다음 감마(γ)와 베타(β)라는 학습 가능한 파라미터로 다시 스케일과 시프트를 해준다. "정규화된 값에 얼마를 곱하고 얼마를 더할지"를 모델이 스스로 배우는 것이다. 이렇게 하면 학습이 훨씬 빠르고 안정적으로 진행되고, 더 높은 학습률을 써도 발산하지 않으며, 깊은 네트워크에서 그래디언트가 사라지는 문제도 완화된다. ReLU 활성화 후에 MaxPool2D(pool_size=3, strides=2)가 이미지를 절반으로 축소한다. 512×512가 256×256이 된다. 풀링은 작은 영역에서 최대값만 취하는 것인데, 공간 해상도를 줄여서 이후 레이어의 계산량을 줄이면서도 중요한 특징은 보존하는 역할을 한다. 잔차 블록 (Residual Block) 잔차 연결(residual connection)이 왜 필요한가? 신경망을 학습시킬 때 역전파(backpropagation)라는 과정을 거친다. 출력에서 계산한 손실의 그래디언트를 네트워크의 뒤쪽(출력 쪽)에서 앞쪽(입력 쪽)으로 전달하면서 각 레이어의 가중치를 조정한다. 문제는 이 그래디언트가 레이어를 하나 통과할 때마다 조금씩 줄어든다는 것이다. 레이어가 10개면 그래디언트가 10번 곱해지면서 줄어들고, 50개면 50번 줄어든다. 결국 앞쪽 레이어에 도달하는 그래디언트가 거의 0에 가까워져서 학습이 사실상 멈춘다. 이것이 그래디언트 소실(vanishing gradient) 문제다. 놀랍게도 이 때문에 더 깊은 네트워크가 더 얕은 네트워크보다 성능이 나빠지는 역설적인 현상이 관찰되었다. 이론적으로는 더 깊은 네트워크가 최소한 얕은 네트워크만큼은 해야 한다. 추가된 레이어가 "아무것도 안 하기"(항등함수)만 배우면 되니까. 하지만 실제로는 "아무것도 안 하기"조차 배우기 어려웠던 것이다.
잔차 연결의 아이디어는 천재적으로 단순하다. 레이어의 출력에 입력을 그냥 더해버리는 것이다. 일반 블록에서는 입력 x가 들어가면 F(x)가 나온다. 잔차 블록에서는 F(x) + x가 나온다. F(x)는 "입력에서 얼마를 변화시킬 것인가"라는 잔차(residual)를 학습한다. 이것이 왜 그래디언트 소실을 해결하는가? 역전파할 때 그래디언트가 두 경로로 전달되기 때문이다. 하나는 F(x)를 통과하는 경로이고, 다른 하나는 x를 직접 더하는 skip connection 경로다. skip connection 경로로는 그래디언트가 아무런 변형 없이 그대로 통과한다. 아무리 깊은 네트워크여도 그래디언트가 skip connection들을 타고 고속도로처럼 앞쪽 레이어까지 직통으로 도달할 수 있다. 또 다른 관점에서 보면, 레이어가 배워야 하는 것이 바뀌었다. 일반 블록에서는 원하는 출력 자체를 통째로 배워야 하지만, 잔차 블록에서는 "입력에서 뭘 바꿀지"만 배우면 된다. 바꿀 게 없으면 F(x)=0만 배우면 되는데, 이건 모든 가중치를 0으로 만들면 되니까 훨씬 쉽다. "아무것도 안 하기"가 가능해진 것이다. Bottleneck 구조 이 모델의 잔차 블록은 Bottleneck이라는 특별한 구조를 쓴다. Conv2D를 세 번 연속으로 통과하는데, kernel_size가 1, 3, 1 순서다. 첫 번째 1×1 Conv는 채널 수를 절반으로 줄인다. 예를 들어 첫 번째 잔차 블록(ct_module=0)에서는 32채널을 16채널로 줄인다. 1×1 Conv가 뭘 하는 건지 처음에는 이상하게 느껴질 수 있다. 1×1 크기의 필터가 공간적으로 보는 건 픽셀 하나뿐이다. 하지만 채널 방향으로는 모든 채널을 본다. 32채널의 값을 가중합해서 16채널로 압축하는 것이다. 일종의 채널 간 정보 혼합이자 차원 축소다. 두 번째 3×3 Conv가 실제로 공간적인 특징을 추출하는 핵심 연산이다. 그런데 이 시점에서 채널이 16개로 줄어 있으니 계산량이 크게 줄어든다. 3×3 Conv의 파라미터 수와 계산량은 입력 채널 수에 비례하므로, 채널을 절반으로 줄이면 계산량도 대략 절반이 된다. 세 번째 1×1 Conv는 채널을 다시 원래 수(32)로 복원한다. 이래야 입력(res_in)과 출력(res_a)의 shape이 같아서 둘을 더할 수 있다. 잔차 연결 res_out = res_in + res_a에서 두 텐서의 shape이 반드시 일치해야 하기 때문이다. 이 Bottleneck 구조의 핵심은 "비싼 연산(3×3 Conv)은 좁은 채널에서 하고, 채널 조절(1×1 Conv)은 싸게 한다"는 것이다. 결과적으로 표현력을 유지하면서 계산 비용을 크게 줄인다. 512×512 고해상도 이미지를 다루는 모델에서 이 효율성은 실용적으로 매우 중요하다. 각 Bottleneck 블록 뒤에는 stride=2인 Conv2D가 이미지를 절반으로 축소하면서 동시에 채널 수를 두 배로 늘린다. 다섯 번 반복하면 256×256×32에서 시작해서 128×128×64, 64×64×128, 32×32×256, 16×16×512, 8×8×1024로 변해간다. 공간 해상도는 줄어들고 채널 수는 늘어나는 이 패턴은 CNN 설계의 표준적인 원칙이다. 초반에는 공간적으로 넓은 영역에서 단순한 특징을 소수의 필터로 잡고, 후반에는 좁은 영역에서 복잡하고 추상적인 특징을 다수의 필터로 잡는다. n_downsample=6 → MaxPool(÷2) + 5번의 stride=2 Conv = 총 64배 축소 (512/64=8) 글로벌 최대 풀링: 위치보다 존재가 중요하다 다섯 개의 잔차 블록을 통과하면 8×8×1024 텐서가 나온다. 8×8은 원래 512×512의 공간 정보가 극도로 압축된 것이고, 1024는 그 위치에서 추출된 추상적 특징의 수다. 이제 이 3차원 텐서를 1차원 벡터로 바꿔야 최종 분류기(Dense 레이어)에 넣을 수 있다. 여기서 선택지가 있다. Flatten을 쓰면 8×8×1024 = 65,536개의 숫자가 된다. 이게 Dense 레이어로 들어가면 가중치 수가 65,536 × 1,024 = 6,700만 개가 된다. 파라미터가 폭발적으로 많아져서 과적합 위험이 크고 메모리도 많이 잡아먹는다. 대신 이 모델은 글로벌 최대 풀링을 쓴다. 1024개 채널 각각에서 8×8 = 64개 값 중 최대값 하나만 뽑는다. 결과는 길이 1,024의 벡터다. 65,536에서 1,024로 64배 압축이다. 왜 평균이 아니라 최대값인가? 이건 망막 이미지의 의학적 특성과 관련이 있다. 당뇨병성 망막증의 징후는 출혈 반점이나 비정상 혈관 같은 국소적인 이상이다. 이런 이상은 이미지의 한 곳에만 있어도 진단에 결정적이다. 글로벌 최대 풀링은 "이 특징이 이미지 어딘가에서 가장 강하게 나타난 정도"를 포착한다. 출혈 반점을 감지하는 채널이 있다면, 이미지의 어느 위치에서든 출혈이 하나라도 발견되면 그 채널의 최대값이 높게 나올 것이다. 글로벌 평균 풀링은 모든 위치의 값을 균등하게 합산하므로, 이미지 대부분이 정상이고 한 곳에만 출혈이 있을 때 그 신호가 희석될 수 있다. 최대 풀링은 희소하지만 결정적인 특징을 놓치지 않는다. 완전 연결 레이어와 분류기 글로벌 최대 풀링으로 얻은 1,024차원 벡터가 Dense(1024, activation=relu) 레이어를 통과한다. 이 레이어에는 L2 정규화가 걸려 있다. L2 정규화의 아이디어는 손실 함수에 가중치의 제곱합을 벌점으로 추가하는 것이다. 원래 손실이 "분류를 얼마나 틀렸나"를 재는 거라면, L2 정규화가 추가된 손실은 "분류를 얼마나 틀렸나 + 가중치가 얼마나 큰가"를 동시에 재는 것이다. λ=0.1이라는 계수가 이 벌점의 세기를 조절한다. 왜 가중치가 크면 안 좋은가? 가중치가 크다는 건 모델이 입력의 작은 변화에도 출력이 크게 흔들린다는 뜻이다. 훈련 데이터의 잡음에도 민감하게 반응해서 과적합하기 쉽다. L2 정규화는 가중치를 작게 유지하라는 압력을 줘서, 모델이 입력의 본질적인 특징에만 반응하고 잡음은 무시하도록 유도한다. 마지막 Dense(5)가 5개의 숫자(logit)를 내놓고, Softmax가 이걸 확률로 변환한다. Softmax는 sigmoid의 다중 클래스 일반화다. sigmoid가 하나의 logit을 0~1 확률로 바꾸는 거였다면, Softmax는 여러 개의 logit을 받아서 각각의 확률로 바꾸되, 전체 합이 반드시 1이 되게 만든다. 각 logit에 지수함수(exp)를 취한 다음 전체 합으로 나누는 것이다. logit이 [-0.5, 2.1, 0.3, -1.2, 0.8]이면, 2.1이 가장 크므로 등급 1의 확률이 가장 높게 나온다. Softmax를 통과하면 [0.06, 0.68, 0.12, 0.03, 0.11] 같은 확률 분포가 된다. 모델은 "이 망막은 68% 확률로 등급 1이다"라고 말하는 셈이다. 이전 DNA 분석에서는 sigmoid를 써서 하나의 확률(결합할 확률)을 내놓았다. 그건 이진 분류였으니까. 여기서는 5개 등급 중 하나를 골라야 하니 Softmax가 자연스러운 선택이다. 이전의 SigmoidCrossEntropy가 이진 분류용이었다면, SparseSoftmaxCrossEntropy는 다중 클래스 분류용이다. "Sparse"가 붙은 이유는 정답 레이블이 원-핫 벡터([0,1,0,0,0])가 아니라 정수(1)로 주어지기 때문이다. 내부적으로 이 정수를 원-핫으로 변환해서 계산하지만, 개발자가 직접 변환할 필요가 없어서 편리하다. 원리는 SigmoidCrossEntropy와 동일한 맥락이다. 모델이 정답 클래스에 높은 확률을 부여하면 손실이 작고, 낮은 확률을 부여하면 손실이 크다. 그리고 SigmoidCrossEntropy가 수치 안정성을 위해 logit을 직접 받았듯이, SparseSoftmaxCrossEntropy도 softmax 적용 전의 logit을 받아서 내부적으로 softmax와 cross entropy를 한 번에 안정적으로 계산한다. 그래서 모델이 output(확률)과 logit_pred(raw logit) 두 개를 출력하고, output_types=['prediction', 'loss']로 각각의 용도를 지정하는 것이다. 이 패턴은 DNA 분류 모델에서도 봤던 것과 정확히 같다. 정리 512×512×3 컬러 이미지가 들어온다. DRAugment가 정규화하고 학습 시 증강을 적용한다. 7×7 Conv가 32개의 기본 특징을 뽑고 MaxPool이 256×256으로 축소한다. 다섯 개의 Bottleneck 잔차 블록이 점점 더 추상적인 특징을 학습하면서 8×8×1024까지 압축한다. 글로벌 최대 풀링이 1024차원 벡터를 만들고, L2 정규화된 Dense(1024)가 특징을 통합하고, Dense(5) + Softmax가 5개 등급에 대한 확률 분포를 출력한다. 이 전체 구조가 하는 일은 결국 하나다. 512×512의 수십만 픽셀 속에서 출혈 반점, 비정상 혈관, 부종 같은 의학적 징후를 자동으로 찾아내서, 이 망막이 5개 등급 중 어디에 해당하는지를 확률적으로 판단하는 것이다.
2026-02-27 ⋯ ResNet 망막증 분류 #3 데이터 증강
딥러닝 모델은 데이터를 많이 볼수록 잘 배운다. 그런데 의료 이미지는 구하기 어렵다. 수만 장이 있다고 해도 수백만 개의 파라미터를 가진 신경망을 학습시키기에는 부족할 수 있다. 데이터가 부족하면 모델은 훈련 이미지의 세부 사항까지 통째로 외워버리고, 처음 보는 이미지에서는 엉뚱한 판단을 내린다. 과적합이다. 데이터 증강의 핵심 아이디어는 하나의 이미지를 살짝씩 변형해서 여러 버전을 만들면, 모델 입장에서는 마치 더 많은 데이터를 본 것과 같은 효과가 난다. 원본 망막 사진을 좌우로 뒤집고, 상하로 뒤집고, 회전하고, 밝기를 바꾸고, 약간 확대하면, 하나의 사진에서 수십 가지 변형이 나온다. 이 변형들은 모두 같은 등급의 같은 망막이지만, 픽셀 배열은 전부 다르다.모델은 이 변형들을 보면서 "뒤집어도 같은 등급이고, 어두워져도 같은 등급이다"라는 사실을 깨닫게 된다. 특정 이미지의 특정 밝기나 특정 방향에 의존하지 않고, 진짜로 등급을 결정하는 의학적 특징(출혈 반점의 유무, 비정상 혈관의 패턴)에 집중하게 되는 것이다. DRAugment: 커스텀 Keras 레이어 보통 데이터 증강은 모델 밖에서 한다. 이미지를 모델에 넣기 전에 따로 전처리 코드를 돌리는 식이다. 그런데 이 코드에서는 DRAugment를 Keras 레이어로 만들어서 모델 안에 내장시켰다. 왜 이렇게 했을까? 가장 큰 이유는 학습과 추론에서 다르게 동작해야 하기 때문이다. 학습할 때는 증강을 적용해서 다양한 변형을 보여줘야 하지만, 실제로 예측할 때(새로운 환자의 사진을 판독할 때)는 원본 이미지를 있는 그대로 넣어야 한다. 예측할 때 이미지를 랜덤으로 뒤집으면 매번 다른 결과가 나올 테니 말이 안 된다. Keras 레이어로 만들면 training이라는 플래그 하나로 이 전환이 자동으로 이루어진다. model.fit()을 호출하면 training=True가 되어 증강이 켜지고, model.predict()를 호출하면 training=False가 되어 증강이 꺼진다. 개발자가 따로 신경 쓸 필요가 없다. 또 하나의 이점은 정규화가 모델에 포함된다는 것이다. 코드의 첫 줄에서 inputs / 255.0을 한다. 원본 이미지의 픽셀값은 0-255 범위인데, 신경망은 0-1 범위의 작은 숫자를 다룰 때 학습이 훨씬 안정적이다. 이 나눗셈을 모델 안에 넣어두면, 모델을 저장하고 나중에 불러올 때 별도로 정규화 코드를 챙기지 않아도 된다. 모델 자체가 "나한테는 원본 이미지를 그대로 넣어줘, 내가 알아서 정규화할게"라고 말하는 셈이다. 첫 번째 증강은 기하학적 변형이다. 좌우 뒤집기, 상하 뒤집기, 그리고 90도 단위의 회전이다. 이 변형들이 망막 이미지에 특히 적합한 이유가 있다. 망막은 본질적으로 방향성이 없다. 안저 카메라로 찍을 때 카메라가 어떤 각도로 기울어졌느냐에 따라 같은 망막도 다르게 찍힐 수 있다. 왼쪽 눈과 오른쪽 눈은 좌우 대칭이다. 출혈 반점이 사진의 왼쪽에 있든 오른쪽에 있든 같은 등급이다. 따라서 이미지를 뒤집거나 회전해도 의학적 의미가 전혀 변하지 않는다. 등급 3인 망막을 좌우로 뒤집어도 여전히 등급 3이다. 이건 모든 이미지에 해당하는 이야기가 아니다. 예를 들어 글자가 적힌 문서 이미지를 좌우로 뒤집으면 글자가 거울상이 되어 완전히 다른 의미가 된다. 숫자 6을 180도 회전하면 9가 된다. 이런 경우에는 뒤집기나 회전을 증강으로 쓸 수 없다. 망막 이미지는 이런 방향 의존성이 없기 때문에 기하학적 변형을 마음껏 쓸 수 있는 것이다. rot90에서 k=np.random.randint(0, 4)는 0도, 90도, 180도, 270도 중 하나를 랜덤하게 고른다는 뜻이다. 좌우 뒤집기(2가지) × 상하 뒤집기(2가지) × 회전(4가지)의 조합으로 기하학적 변형만으로도 하나의 이미지에서 최대 16가지 변형을 만들어낼 수 있다. 두 번째 증강은 색상 왜곡이다. 밝기를 ±32/255 범위에서 랜덤하게 바꾸고, 채도를 0.5배에서 1.5배 사이로 랜덤하게 조절한다. 왜 이게 필요할까? 현실에서 안저 사진의 색상은 촬영 조건에 따라 크게 달라진다. 카메라 장비가 다르면 색감이 다르고, 조명 강도에 따라 밝기가 다르고, 환자의 눈 색소량에 따라 전체적인 톤이 다르다. 같은 등급의 같은 병변이어도 사진마다 색이 다른 것이다. 색상 왜곡은 이런 현실적 변동을 인위적으로 만들어낸다. 모델이 "이 사진이 좀 어두운데 등급 2다" "이 사진은 밝은데 역시 등급 2다" "이건 채도가 낮은데 등급 2다"라는 다양한 버전을 보면서, 색상이 아닌 구조적 특징(출혈의 형태, 혈관의 비정상적 패턴)에 집중하게 된다. random_brightness의 max_delta=32/255는 약 12.5%의 밝기 변동이다. 너무 크면 이미지가 하얗게 날아가거나 까맣게 뭉개져서 정보가 손실되고, 너무 작으면 증강 효과가 미미하다. 32/255는 눈에 띄는 변화이면서도 이미지의 의학적 정보를 파괴하지 않는 적절한 범위다.
random_saturation의 0.5~1.5 범위도 비슷한 논리다. 채도를 0.5배로 낮추면 색이 빠져서 약간 회색빛이 되고, 1.5배로 높이면 색이 과장된다. 두 극단 모두 현실적으로 촬영 장비 차이에서 충분히 발생할 수 있는 범위다. clip_by_value(img, 0.0, 1.0)은 안전장치다. 밝기를 올리거나 채도를 높이면 일부 픽셀값이 1.0을 초과하거나 0.0 미만으로 떨어질 수 있다. 이런 비정상적인 값을 0~1 범위 안으로 잘라주는 것이다. 이미지 데이터에서 이 범위를 벗어나는 값은 의미가 없으므로 반드시 잘라줘야 한다. 세 번째 증강은 중앙 크롭이다. 이미지의 중앙 80%-100%를 랜덤하게 잘라낸 다음, 다시 원래 크기(512×512)로 리사이즈한다. 이걸 이해하려면 이전 단계의 전처리를 떠올려야 한다. data.py에서 Canny 엣지 검출로 망막 중심을 찾아서 정사각형으로 잘랐다. 하지만 이 중심 추정이 항상 완벽한 건 아니다. 몇 픽셀 정도는 어긋날 수 있다. 그리고 실제 진료 현장에서도 카메라 위치가 미세하게 다르면 망막이 사진 안에서 약간씩 다른 위치에 찍힌다.
중앙 크롭은 이런 미세한 위치 변동을 시뮬레이션한다. 100%로 크롭하면 원본 그대로이고, 80%로 크롭하면 가장자리 10%씩이 잘려나가면서 약간 확대된 효과가 난다. 이걸 랜덤하게 반복하면 모델은 "망막이 화면에서 약간 왼쪽으로 치우쳐 있어도, 약간 확대되어 보여도 같은 등급이다"라는 걸 배우게 된다. 크롭 비율을 정하는 방식이 재미있다. np.random.normal(1.0, 0.06)은 평균 1.0, 표준편차 0.06인 정규분포에서 숫자를 뽑는다. 대부분의 경우 0.94-1.06 범위의 값이 나온다. 즉 대체로 원본에 가깝되 살짝만 다른 크롭을 만든다. 가끔 극단적인 값이 나올 수 있으니 np.clip으로 0.8-1.0 범위를 벗어나지 못하게 제한한다. 1.0보다 큰 값은 의미가 없으므로(이미지 바깥을 볼 수는 없으니까) 상한을 1.0으로 둔 것이고, 0.8보다 작으면 너무 많이 잘려서 중요한 정보가 사라질 수 있으므로 하한을 0.8으로 둔 것이다. 크롭 후에는 이미지 크기가 줄어들었으므로 tf.image.resize로 다시 512×512로 복원한다. 여기서 tf.expand_dims로 차원을 하나 추가하는 건 resize 함수가 배치 차원을 요구하기 때문이고, [0]으로 다시 빼주는 건 resize 후에 추가된 차원을 제거하기 위해서다. 순수한 기술적 요구사항이다. 마지막 줄의 tf.map_fn(preprocess, parent_tensor)은 배치 안의 모든 이미지에 preprocess 함수를 하나씩 적용한다는 뜻이다. 이게 중요한 이유는 각 이미지에 서로 다른 랜덤 변형이 적용되어야 하기 때문이다. 만약 배치 전체에 같은 변형을 적용하면(예를 들어 1,000장 모두 똑같이 좌우 반전), 모델은 "이번 배치는 전부 뒤집힌 거구나"라는 배치 수준의 패턴을 잡아버릴 수 있다. 각 이미지가 독립적으로 랜덤하게 변형되어야 모델이 개별 이미지의 의학적 특징에 집중할 수 있다. 정리 학습 중에 하나의 망막 이미지가 DRAugment 레이어를 통과할 때 벌어지는 일은 이렇다. 먼저 0-255 범위의 픽셀값이 0-1로 정규화된다. 그 다음 좌우 뒤집기, 상하 뒤집기, 90도 회전이 랜덤하게 적용되어 기하학적 변형이 만들어진다. 밝기와 채도가 랜덤하게 조절되어 다양한 촬영 조건이 시뮬레이션된다. 마지막으로 중앙 부분이 랜덤한 비율로 크롭되어 미세한 위치 변동이 반영된다. 매 에포크마다 같은 이미지에 다른 랜덤 변형이 적용되므로, 모델은 200 에포크 동안 사실상 같은 이미지를 두 번 보지 않는 셈이다. 추론할 때는 이 모든 랜덤 과정이 꺼지고, 정규화만 적용된 깨끗한 이미지가 모델에 들어간다. 학습 때는 일부러 어렵고 다양한 조건을 주어서 단련시키고, 시험 때는 정석대로 평가하는 것이다.
2026-02-27 ⋯ ResNet 망막증 분류 #2 이미지 전처리
망막 이미지 데이터 Kaggle에서 받은 안저 사진들은 전 세계 다양한 병원에서 서로 다른 장비로 찍힌 것이다. 어떤 사진은 2000픽셀짜리이고, 어떤 사진은 5000픽셀이 넘는다. 어떤 사진은 거의 정사각형이고, 어떤 사진은 직사각형이다. 공통적인 건 하나인데, 망막은 원형이라 사진 한가운데에 둥근 밝은 영역으로 찍히고, 그 주변은 까만 여백으로 채워져 있다는 것이다. 신경망에 이미지를 넣으려면 모든 이미지의 크기가 같아야 한다. 행렬 연산이 고정된 차원을 요구하기 때문이다. 그리고 까만 여백은 아무런 의학적 정보가 없는데도 픽셀을 차지하고 있으니 낭비다. 따라서 전처리에서 할일은 각 이미지에서 망막이 있는 부분만 정확히 잘라내서, 모두 동일한 512×512 크기로 맞추는 것이다. 여기서 망막의 정확한 위치가 사진마다 다르다. 어떤 사진에서는 약간 왼쪽으로 치우쳐 있고, 어떤 사진에서는 위쪽으로 치우쳐 있다. 사람이 수만 장을 하나하나 보고 잘라낼 수는 없으니, 자동으로 망막의 중심을 찾아서 잘라내는 알고리즘이 필요하다. Canny 엣지 검출: 망막 경계 찾기 망막의 중심을 찾으려면 먼저 망막이 어디에 있는지를 알아야 한다. 여기서 Canny 엣지 검출이라는 고전적인 컴퓨터 비전 기법이 등장한다. Canny의 핵심 아이디어는 이미지에서 밝기가 급격하게 변하는 곳을 찾는다. 안저 사진을 생각해보자. 까만 여백 영역은 픽셀값이 거의 0이다. 망막 영역은 밝아서 픽셀값이 200 정도 된다. 이 둘의 경계에서 픽셀값이 0에서 200으로 갑자기 뛴다. Canny는 바로 이 급격한 변화를 감지해서 "여기가 경계(엣지)다"라고 표시한다. cv2.Canny(img, 10, 30)에서 10과 30은 임계값이다. 밝기 변화가 30보다 크면 확실한 엣지로 판정하고, 10보다 작으면 엣지가 아니라고 무시한다. 10과 30 사이의 변화는 조건부로 판정하는데, 이미 확인된 강한 엣지에 연결되어 있으면 엣지로 인정하고 그렇지 않으면 버린다. 이 두 단계 판정 방식 덕분에 중요한 경계는 놓치지 않으면서 무의미한 잡음은 걸러낼 수 있다. 임계값을 10과 30으로 아주 낮게 잡은 이유는 안저 사진의 특성 때문인데, 까만 배경에서 망막 영역으로 넘어가는 경계는 대비가 뚜렷하니까 낮은 임계값으로도 충분히 잡히고, 오히려 너무 높이 잡으면 대비가 약한 사진에서 경계를 놓칠 수 있다. 중심 찾기: 1%와 99% 지점의 평균 Canny를 돌리면 엣지로 판정된 픽셀들의 좌표 목록이 나온다. 이 좌표들은 대부분 망막의 둥근 경계를 따라 분포해 있을 것이다. 이 좌표들로부터 망막의 중심을 어떻게 구할까? 가장 단순한 방법은 모든 엣지 좌표의 평균을 내는 것이다. 하지만 이 방법에는 함정이 있다. 이미지 구석에 찍힌 먼지 자국이나 촬영 장비의 문자 표시 같은 것도 엣지로 잡힐 수 있는데, 이런 이상치(outlier)가 평균을 크게 왜곡할 수 있다. 그래서 엣지 좌표들을 y축 기준으로 정렬한 다음, 맨 위 1% 지점의 y값과 맨 아래 99% 지점의 y값의 평균을 수직 중심으로 잡는다. x축에 대해서도 똑같이 한다. 왼쪽 1% 지점과 오른쪽 99% 지점의 x값 평균이 수평 중심이 된다. 1%와 99%를 쓰면 양쪽 끝 1%의 극단값을 무시하는 효과가 있다. 통계에서 말하는 "트림드 평균(trimmed mean)"과 비슷한 원리다. 이렇게 하면 잡음에 덜 민감한 견고한 중심 추정이 가능하다. 정사각형 자르기 중심을 찾았으면 이제 그 중심을 기준으로 정사각형을 잘라야 한다. 여기서 edge_size라는 값을 계산하는데, 이건 중심에서 이미지 경계까지의 최소 거리다. 중심에서 위쪽 경계까지, 아래쪽 경계까지, 왼쪽 경계까지, 오른쪽 경계까지 네 가지 거리 중 가장 짧은 것을 고른다. 왜 최소 거리를 고르느냐? 정사각형이 이미지 바깥으로 삐져나가면 안 되기 때문이다. 중심이 이미지의 정 가운데에 있으면 네 방향의 거리가 비슷하겠지만, 중심이 약간 왼쪽으로 치우쳐 있으면 왼쪽 경계까지의 거리가 가장 짧다. 이 가장 짧은 거리를 반지름으로 삼아야 정사각형이 이미지 안에 안전하게 들어간다. 중심에서 상하좌우로 edge_size만큼 자르면 변의 길이가 2 × edge_size인 정사각형이 된다. 이 정사각형의 크기는 이미지마다 다르다. 원본이 큰 이미지에서는 큰 정사각형이 나오고, 작은 이미지에서는 작은 정사각형이 나온다. 마지막으로 cv2.resize로 이 정사각형을 512×512로 통일한다. 이제 모든 이미지가 같은 크기이고, 망막이 중앙에 위치하며, 불필요한 검정 여백이 최소화된 깔끔한 상태가 된다. 결과물은 cut_원본파일명.png라는 이름으로 저장된다. 클래스 불균형 처리 CSV 파일에 각 이미지의 등급(0~4)이 적혀 있고, 이걸 파이썬 딕셔너리로 변환한다. 파일명에서 "cut_"과 확장자를 떼어내면 원래 이미지 이름이 나오고, 딕셔너리에서 해당 이름의 등급을 찾을 수 있다. 여기서 문제는 등급별 이미지 수가 극심하게 불균형하다. 현실 세계에서는 당연한 일이다. 대부분의 당뇨 환자는 망막증이 없거나 경미하고, 중증 이상인 환자는 상대적으로 드물다. 그래서 등급 0(정상)이 25,000장인 반면 등급 3(중증)은 800장 정도밖에 안 된다. 30배가 넘는 차이다. 모델의 목표는 손실을 최소화하는 것이다. 만약 모든 이미지에 대해 "등급 0"이라고 찍어버리면 어떻게 될까? 25,000장은 맞추고 나머지만 틀린다. 전체 정확도가 이미 70% 이상이다. 모델이 아무것도 안 배우고 그냥 "다 정상이에요"라고 대답해도 그럭저럭 괜찮은 성적을 받는 것이다. 이러면 등급 3, 4 같은 심각한 케이스를 전혀 잡아내지 못하는 쓸모없는 모델이 된다. 이 문제를 해결하는 방법이 클래스 가중치다. 아이디어는 직관적이다. 희귀한 클래스의 오분류에 더 큰 벌점을 주는 것이다.
가중치 계산 방식을 따라가보자. 먼저 가장 많은 클래스의 샘플 수를 찾는다. 여기서는 등급 0의 25,000이다. 각 클래스의 가중치는 (최대 샘플 수 / 해당 클래스 샘플 수)로 계산한다. 등급 0은 25,000 / 25,000 = 1.0이다. 가장 흔한 클래스니까 가중치가 1이다. 등급 3은 25,000 / 800 = 31.3이다. 31배나 희귀하니까 가중치도 31배다.
이 가중치가 손실 계산에서 하는 역할을 구체적으로 생각해보자. 모델이 등급 0 이미지를 틀리면 손실에 1.0이 곱해진다. 그냥 원래 손실 그대로다. 모델이 등급 3 이미지를 틀리면 손실에 31.3이 곱해진다. 같은 크기의 실수여도 31배 더 큰 벌점을 받는 것이다. 이렇게 하면 모델은 더 이상 "다 정상이라고 하면 편해"라는 안이한 전략을 쓸 수 없게 된다. 등급 3 하나를 틀리는 페널티가 등급 0을 31개 틀리는 것과 맞먹으니까, 모델은 희귀한 중증 사례도 정확히 잡아내는 방향으로 학습할 수밖에 없다. 직관적으로 이건 수업에서 시험 문제의 배점을 조정하는 것과 같다. 쉬운 문제(흔한 클래스)는 1점짜리로 놓고, 어려운 문제(희귀한 클래스)는 31점짜리로 놓으면 학생은 어려운 문제도 무시할 수 없게 된다. 데이터의 양이 부족한 것을 각 샘플의 중요도를 높여서 보상하는 전략인 것이다. 정리 수만 장의 원본 안저 사진이 들어온다. 크기도 제각각이고, 망막 위치도 사진마다 다르고, 주변은 까만 여백으로 채워져 있다. Canny 엣지 검출이 밝기가 급변하는 경계선을 찾아내고, 그 경계선 좌표들의 분포로부터 망막의 중심을 추정한다. 중심에서 가능한 최대 크기의 정사각형을 잘라내서 512×512로 리사이즈한다. 그 다음 CSV에서 등급 레이블을 읽어 연결하고, 클래스별 빈도의 역수로 가중치를 매겨서 데이터 불균형을 보정한다. 이 모든 과정이 끝나면, 깔끔하게 정규화된 이미지와 균형 잡힌 가중치가 준비된다. 이제 이 데이터를 모델에 넣을 수 있는 상태가 된 것이다.
2026-02-27 ⋯ ResNet 망막증 분류 #1 Conv2D 기반 이미지 분류
당뇨병 환자의 몸에서는 혈당이 오랜 기간 높은 상태로 유지된다. 이 높은 혈당이 온몸의 작은 혈관들을 서서히 망가뜨리는데, 눈의 망막에 있는 미세 혈관도 예외가 아니다. 혈관이 손상되면 피가 새고, 비정상적인 새 혈관이 자라나고, 결국 망막이 제 기능을 못 하게 되면서 시력을 잃을 수 있다. 이것이 당뇨병성 망막증이다.
안과 의사는 이 병을 진단하기 위해 안저 사진을 찍는다. 안저란 눈 뒤쪽의 망막 바닥 면을 말하는데, 특수 카메라로 동공을 통해 들여다보면 망막의 혈관 구조가 고스란히 보인다. 의사는 이 사진에서 출혈 반점, 비정상 혈관, 부종 같은 징후를 찾아서 병의 심각도를 판정한다. 문제는 이 판독에 전문성과 시간이 많이 든다. 이에 분석 목적은 안저 사진을 보고 자동으로 심각도를 판정하는 것이다. 데이터셋과 문제 설정 Kaggle Diabetic Retinopathy Detection 데이터셋은 수만 장의 안저 사진이 들어 있고 각 사진에는 안과 의사가 매긴 등급이 붙어 있다. 0은 정상, 1은 경증, 2는 중등도, 3은 중증, 4는 증식성으로 가장 심각한 단계다. 등급 0의 사진을 보면 깨끗한 혈관 구조가 보인다. 등급이 올라갈수록 작은 출혈 반점이 나타나기 시작하고, 혈관이 불규칙해지고, 심한 경우에는 망막 전체에 출혈과 비정상 혈관이 가득 차 있다. 이는 단순 분류가 아닌 순서가 있는 분류이다. 일반적인 고양이, 개, 새를 구분하는 문제에서 고양이를 개로 잘못 분류하나 새로 잘못 분류하나, 둘 다 "한 번 틀린 것"이다. 오분류의 심각도가 동일하다. 하지만 당뇨병성 망막증은 다르다. 등급 0(정상)인 환자를 등급 1(경증)로 잘못 예측하는 건 한 단계 차이니까 그나마 덜 심각하다. 그런데 등급 0을 등급 4(증식성)로 예측하면 어떻게 되나? 정상인 환자에게 가장 심각한 진단을 내리는 셈이다. 반대로 등급 4를 등급 0으로 예측하면? 당장 치료가 필요한 환자를 정상이라고 돌려보내는 것이다. 이건 실명으로 이어질 수 있는 치명적인 실수다. 이렇게 등급 사이에 순서와 거리가 있는 분류 문제를 순서형 분류(Ordinal Classification)라고 부른다. 0, 1, 2, 3, 4라는 숫자가 단순한 이름표가 아니라 심각도의 정도를 나타내는 척도다. 한 단계 차이로 틀리는 것과 네 단계 차이로 틀리는 것의 무게가 완전히 다르다. 그래서 단순 정확도(accuracy)만으로는 모델을 제대로 평가할 수 없다. 정확도는 "맞았느냐 틀렸느냐"만 따지지, "얼마나 크게 틀렸느냐"는 신경 쓰지 않기 때문이다. 등급 0을 등급 1로 예측하든 등급 4로 예측하든 정확도 관점에서는 똑같이 "하나 틀린 것"이다. 하지만 의학적으로, 그리고 환자의 삶에서 이 두 실수의 무게는 하늘과 땅 차이다. 이 문제의 특수성을 반영하는 적절한 평가 방법이 필요한 이유다. 이 분석에서는 3가지 파일로 나뉜 하나의 실험을 한다: | 파일 | 역할 |
|---|---|
| `data.py` | 이미지 전처리 + 데이터셋 로드 |
| `model.py` | ResNet 기반 분류 모델 + 데이터 증강 + 평가 지표 |
| `run.py` | 전체 파이프라인 실행 | data.py로 이미지 전처리와 데이터셋 로딩, model.py로 ResNet 기반의 분류 모델 정의와 데이터 증강 및 평가, run.py로 실제로 학습과 평가를 실행한다. 이전에 DNA 서열을 Conv1D로 분석한 것처럼, 여기서는 망막 사진을 Conv2D 기반의 ResNet으로 분석하는 것이다. 입력이 1차원 서열에서 2차원 이미지로 바뀌었고, 문제가 이진 분류나 회귀에서 순서형 다중 분류로 바뀌었지만, "입력을 숫자로 바꾸고 합성곱으로 패턴을 찾아서 판단을 내린다"는 근본 구조는 동일하다.
2026-02-27 ⋯ Conv1D DNA 분석 #5 분석 정리
Conv1D 기반 DNA 분석에서 세 가지 실험을 했는데 전사인자 결합 예측, 크로마틴 접근성을 추가한 결합 예측, 그리고 RNAi 효율 예측을 수행했다. 세부 사항은 다 달랐지만 DNA(또는 RNA) 서열을 숫자로 바꾸고, 1차원 합성곱 필터로 패턴을 찾고, 그 패턴들을 종합해서 하나의 답을 내놓는 공통 로직으로 작동한다. 모델 구조 Conv1D 레이어
- Conv1D를 한 겹만 쓰지 않고 두세 겹을 쌓는다. 첫 번째 레이어가 원본 서열에서 기본 모티프를 찾으면, 두 번째 레이어는 그 모티프들의 조합을 본다. "이 모티프 옆에 저 모티프가 있다"는 식의 더 복잡한 패턴을 학습하는 것이다. 세 번째 레이어가 있으면 한 단계 더 추상적인 패턴까지 잡아낸다.
- 실험마다 레이어 수가 달랐다. 101글자짜리 전사인자 문제에서는 3겹, 21글자짜리 RNAi 문제에서는 2겹을 썼다. 이는 데이터의 복잡도에 맞춘 선택인데 서열이 길고 패턴이 복잡할수록 더 깊은 모델이 필요하고, 서열이 짧고 단순하면 얕은 모델로 충분하다. 모델을 불필요하게 크게 만들면 훈련 데이터의 잡음까지 외워버리는 과적합이 생기고, 너무 작게 만들면 중요한 패턴을 놓치는 과소적합이 생긴다. 그 사이의 적절한 지점을 찾는 것이 모델 설계의 핵심이다. Flatten-Dense
- Conv1D를 다 통과하면 "서열의 어디에 어떤 패턴이 있는가"를 요약한 특징 맵이 나온다. 전사인자 모델에서는 (101, 15), RNAi 모델에서는 (21, 10) 형태다. Flatten이 이 2차원 구조를 1차원 벡터로 쭉 펼치고, Dense(1)이 그 벡터의 모든 원소에 가중치를 곱해 더해서 숫자 하나를 만든다. Sigmoid가 이 숫자를 0~1 사이로 눌러준다.
- 여기서 크로마틴 접근성 실험(실험 2)만 Flatten 뒤에 접근성 스칼라 하나를 Concatenate로 이어붙여서, Dense가 서열 패턴과 크로마틴 상태를 동시에 고려하게 만들었다. 이건 "같은 모델 구조에 외부 정보를 주입하는 가장 간단한 방법"을 보여주는 사례다. 서열 분석은 Conv1D한테 맡기고, 그 결과와 외부 정보를 최종 판단 직전에 합치는 전략이다. Conv1D가 DNA 분석에 적절한것은 3가지 이유가 있다. 첫째는 위치 불변성이다. JUND가 좋아하는 모티프 TGACTCA가 서열의 앞쪽에 있든 중간에 있든 끝쪽에 있든, 그건 같은 모티프다. Conv1D의 필터는 서열 전체를 처음부터 끝까지 미끄러지면서 같은 패턴을 찾으므로, 모티프가 어디에 위치하든 놓치지 않는다. 만약 Conv1D 대신 일반 Dense 레이어만으로 모델을 만들었다면, "3번째 위치의 TGACTCA"와 "50번째 위치의 TGACTCA"를 완전히 다른 것으로 취급했을 거다. 같은 패턴을 위치마다 따로따로 배워야 하니 데이터가 훨씬 많이 필요하고 효율이 떨어진다. 둘째는 파라미터 공유다. 필터 하나가 서열의 모든 위치에 동일하게 적용된다. 101개 위치를 훑더라도 필터의 가중치는 하나뿐이다. 이건 메모리와 계산 효율 면에서 엄청난 이점이다. Dense 레이어로 같은 일을 하려면 각 위치에 별도의 가중치가 필요하므로 파라미터 수가 폭발적으로 늘어난다. 파라미터가 적다는 건 적은 데이터로도 잘 학습할 수 있다는 뜻이기도 하다. 셋째는 지역성이다. DNA 모티프는 대부분 5-15개 염기 길이다. JUND의 핵심 결합 모티프는 7-8글자 정도다. kernel_size=10이면 한 번에 10개 염기를 보는 창이니, 이런 모티프를 한 눈에 포착하기에 딱 맞는 크기다. DNA에서 생물학적으로 의미 있는 신호는 대부분 이웃한 염기들 사이의 국소적 패턴이지, 서열의 양 끝에 있는 글자들 사이의 장거리 관계가 아니다. Conv1D는 바로 이 국소적 패턴을 찾도록 설계된 도구다. 이 세 가지 성질이 합쳐져서, Conv1D는 DNA 서열 분석에 거의 이상적인 도구가 된다. 모티프라는 짧고 반복적인 패턴을 서열 어디에서든 효율적으로 찾아낸다. 손실 함수 선택 기준 모델의 뼈대는 같지만, 문제가 분류냐 회귀냐에 따라 반드시 바꿔야 하는 부품이 손실 함수와 평가 지표이다. 분류 문제(전사인자 결합)에서는 SigmoidCrossEntropy를 손실로 쓴다. 이 함수는 "정답이 1인데 0에 가깝게 예측하면 엄청나게 벌주고, 정답이 0인데 1에 가깝게 예측해도 엄청나게 벌준다"는 식으로 작동한다. 자신감 있게 틀리는 것에 기하급수적인 페널티를 부과해서, 모델이 양성과 음성을 확실히 구분하도록 밀어붙인다. 평가는 ROC-AUC로 한다. "임의의 양성-음성 쌍에서 양성의 점수가 더 높을 확률"이라는 직관적인 의미를 가진 지표다. 회귀 문제(RNAi 효율)에서는 L2Loss를 쓴다. 예측값과 실제값의 차이를 제곱한 것이 전부다. 큰 오차에 불균형적으로 큰 벌점을 줘서 극단적인 실수를 줄이는 성질이 있다. 평가는 피어슨 상관계수로 한다. 예측값과 실제값이 함께 오르내리는 정도, 즉 선형적 동조 정도를 재는 지표다. 이 선택이 틀리면 모델이 제대로 학습하지 못한다. 회귀 문제에 이진 교차 엔트로피를 쓰면 손실 함수가 연속값을 제대로 처리하지 못하고, 분류 문제에 L2Loss를 쓰면 확률 분포를 최적화하는 데 비효율적이다. 도구를 작업에 맞게 고르는 것, 이것이 딥러닝에서 "설계"라고 부르는 행위의 상당 부분을 차지한다. | 문제 유형 | 손실 함수 | 평가 지표 |
|---|---|---|
| 이진 분류 (TF 결합) | SigmoidCrossEntropy | ROC-AUC |
| 회귀 (RNAi 효율) | L2Loss (MSE) | Pearson r | cf 데이터 흐름 원본 데이터는 DiskDataset이라는 형태로 디스크에 저장되어 있다. shard라는 조각 파일들로 나뉘어 있고, 각 shard에는 서열(X), 정답(y), 가중치(w), ID(ids)가 들어 있다. 단일 입력 모델(실험 1, 3)에서는 DeepChem의 표준 파이프라인이 알아서 배치를 만들어 모델에 넣어준다. model.fit()을 호출하면 끝이다.
다중 입력 모델(실험 2)에서는 표준 파이프라인이 접근성 데이터를 끼워넣을 방법이 없으므로, 커스텀 제너레이터를 직접 만들었다. 이 제너레이터가 배치를 하나씩 만들 때마다 ID로 접근성 딕셔너리를 조회해서 서열과 접근성을 함께 묶어서 내놓는다. fit_generator가 이 제너레이터에서 배치를 받아 학습한다. 즉 프레임워크의 표준 기능으로 해결되면 그걸 쓰고, 안 되면 직접 만들면 된다. 제너레이터라는 파이썬의 기본 기능 하나로 데이터 파이프라인 전체를 커스터마이즈할 수 있다는 사실이, 이 생태계의 유연함을 잘 보여준다. cf2 모델링 핵심 요약 서열을 원-핫으로 숫자화하는 건 모든 실험에서 동일하다. 이건 DNA/RNA 서열을 신경망에 넣기 위한 사실상의 표준이다. Conv1D로 모티프를 탐지하는 것도 동일하다. 서열 데이터의 1차원적, 국소적, 위치 독립적 특성에 정확히 맞는 도구이기 때문이다. Flatten과 Dense로 최종 판단을 내리는 구조도 동일하다.
바뀌는 건 세 가지뿐이다. 모델의 크기(레이어 수, 필터 수, Dropout 비율)는 데이터의 복잡도에 맞게 조절한다. 추가 입력이 있으면 Concatenate로 합류시킨다. 손실 함수와 평가 지표는 문제 유형(분류 vs 회귀)에 따라 선택한다. 궁극적으로 목적은 "이 서열 안에 있는 어떤 패턴이 우리가 예측하려는 결과와 관련이 있는가?"이고 Conv1D는 그 패턴을 찾는 돋보기이고, Dense는 찾은 패턴들을 종합해서 답을 내놓는 판사이며, 손실 함수는 판사가 얼마나 틀렸는지를 알려주는 채점관이다. 이 세 부품의 협업이, 서열이라는 문자열 속에 숨겨진 생물학적 신호를 끄집어내는 것이다.
2026-02-27 ⋯ Conv1D DNA 분석 #4 RNAi 효율 예측
RNA 간섭(RNAi)이란? 세포 안에서 유전자가 단백질을 만드는 과정은 DNA에서 mRNA라는 복사본이 만들어지고, 이 mRNA를 리보솜이라는 기계가 읽어서 단백질을 찍어낸다. 유전자 → mRNA → 단백질, 이 흐름이 생명의 기본 공정이다. 그런데 세포 안에 짧은 RNA 조각을 집어넣으면, 그 RNA가 특정 mRNA를 찾아가서 분해한다. mRNA가 사라지면 리보솜이 읽을 게 없으니 단백질도 안 만들어진다 즉 특정 유전자를 "조용히 시키는" 것이다. 이걸 RNA 간섭, RNAi라고 한다. 이때 집어넣는 짧은 RNA를 siRNA(small interfering RNA)라고 하는데, 길이가 딱 21개 염기다. 21글자짜리 문자열 하나가 특정 유전자를 꺼버리는 스위치 역할을 하는 셈이다. 이게 왜 중요하냐면, 약으로 쓸 수 있기 때문이다. 질병을 일으키는 유전자가 있으면, 그 유전자의 mRNA를 겨냥하는 siRNA를 설계해서 넣으면 된다. 실제로 RNAi 기반 치료제가 이미 시판되고 있다. 문제는 아무 21글자 서열이나 잘 작동하는 게 아니라는 것이다. 같은 유전자를 겨냥하더라도 어떤 siRNA 서열은 mRNA를 90% 넘게 분해하고, 어떤 서열은 10%밖에 분해하지 못하는 등 서열의 구성에 따라 효율이 다르다. 이유는 여러가지가 있는데 siRNA의 특정 위치에 어떤 염기가 오느냐에 따라 RISC라는 단백질 복합체와의 결합 효율이 달라지고, 서열의 열역학적 안정성(얼마나 단단하게 결합하는가)도 영향을 미치고, mRNA의 표적 부위가 접근 가능한 구조인지도 관계된다. 이러한 siRNA를 하나하나 실험으로 테스트하는 건 시간과 돈이 많이 든다. 이에 분석 목적은 서열만 보고 "이 siRNA는 효율이 높을 거야"라고 미리 예측할 수 있는 모델을 만드는것이다. 이전의 전사인자 결합 예측과 겉모습은 비슷한데, DNA(또는 RNA) 서열을 넣으면 뭔가를 예측한다. 하지만 본질적으로 다른 점이 하나 있는데 전사인자 문제에서는 "붙는다" 또는 "안 붙는다", 즉 0 아니면 1인 분류 문제였다. 하지만 RNAi 효율은 0.12일 수도 있고 0.47일 수도 있고 0.93일 수도 있다. 0과 1 사이의 어떤 값이든 될 수 있는 연속적인 숫자이므로 회귀 문제이다. 이에 모델의 손실 함수, 평가 지표가 바뀐다. 모델 구조 이전에는 입력이 길이 101짜리 DNA 조각이었는데, 이번에는 길이 21짜리 siRNA다. 원-핫 인코딩은 동일하게 적용해서 (21, 4) 형태의 행렬이 된다. 101에서 21로 줄었다는 건 단순히 숫자가 작아진 것 이상의 의미가 있다. 서열이 짧다는 건 그 안에 담긴 패턴의 복잡도도 낮다는 뜻이다. 101글자 안에는 여러 모티프가 복잡하게 조합될 수 있지만, 21글자 안에서는 그럴 여지가 훨씬 적다. 그래서 모델도 더 단순해야 한다. Conv1D 레이어가 3개에서 2개로 줄었고 필터 수도 15개에서 10개로 줄었다. 이건 과적합 방지와 직결된다. 데이터가 담고 있는 정보량에 비해 모델이 너무 크면, 모델이 훈련 데이터의 잡음까지 외워버린다. 21글자짜리 서열에 15개 필터 3겹은 과분하다. 10개 필터 2겹이면 21글자 안의 패턴을 충분히 포착할 수 있다. Dropout 비율도 0.5에서 0.3으로 내려갔다. 이전에는 뉴런의 절반을 껐는데, 이번에는 30%만 끈다. 모델 자체가 작아졌으니 너무 많이 끄면 학습할 용량이 부족해지기 때문이다. 큰 팀에서는 절반을 빼도 나머지가 커버할 수 있지만, 작은 팀에서 절반을 빼면 일이 안 돌아가는 것과 같은 원리다. Conv1D의 kernel_size는 여전히 10이다. 21글자 서열에서 10글자씩 보는 창이면 서열의 거의 절반을 한 번에 보는 셈이다. 이 정도면 siRNA 효율에 영향을 미치는 위치별 염기 선호도를 충분히 잡아낼 수 있다. 2겹의 Conv1D를 통과하면 (21, 10) 형태의 특징 맵이 나온다. 21개 위치 각각에 대해 10개 필터가 값을 내놓은 것이다. Flatten이 이걸 210개짜리 1차원 벡터로 펼친다. 이전의 1,515개에 비하면 훨씬 작다. tfbinding 대비 차이점: | | tfbinding | rnai |
|---|---|---|
| 입력 길이 | 101 bp | 21 bp |
| Conv 레이어 수 | 3 | 2 |
| 필터 수 | 15 | 10 |
| Dropout 비율 | 0.5 | 0.3 |
| 출력 | 이진 분류 | 회귀 | 즉 서열이 짧고(21bp) 문제가 단순하므로 더 작은 모델을 사용한다. Dense(units=1, activation=sigmoid)가 210개의 특징을 숫자 하나로 압축하고 sigmoid를 통과시킨다. 이전 모델에서도 sigmoid를 썼고, 이번에도 sigmoid를 썼지만 의미가 다르다. 이전 모델에서 sigmoid의 출력은 "결합할 확률"이었다. 확률이니까 0-1 사이인 게 자연스럽다. 이번 모델에서 sigmoid의 출력은 "유전자 침묵 효율"이다. 이것도 0-1 사이 값이다. 0이면 전혀 효과 없음, 1이면 완벽한 침묵. 출력 범위가 마침 0~1이니까 sigmoid가 잘 맞는 것이다. 만약 예측해야 하는 값의 범위가 0-1이 아니라 임의의 실수였다면 sigmoid를 쓸 수 없었을 거다. 그때는 활성화 함수 없이 raw 출력을 그대로 내보내거나 다른 방법을 써야 한다. 하지만 RNAi 효율은 운 좋게도 0-1 범위에 딱 들어맞으므로 sigmoid가 자연스러운 선택이다. 그리고 한 가지 중요한 구조적 차이가 있다. 이전 분류 모델에서는 출력을 두 개(sigmoid 적용한 확률과 적용 전 logit) 내보냈다. 수치 안정성을 위해 손실 함수에 logit을 직접 넣어야 했기 때문이다. 이번 모델에서는 출력이 하나뿐이다. Dense 레이어에 sigmoid가 바로 붙어 있고, 이 값이 예측에도 쓰이고 손실 계산에도 쓰인다. 이게 가능한 이유는 손실 함수가 바뀌었기 때문이다. 이전 모델은 SigmoidCrossEntropy를 썼다. 그건 "맞다/틀리다"라는 이진 판단에 최적화된 손실 함수다. 이번에는 분류가 아니라 연속값 예측이므로 L2Loss, 즉 평균 제곱 오차(MSE)를 쓴다. 실제값이 0.7인데 모델이 0.8이라고 예측했다면, 손실은 (0.8 - 0.7)² = 0.01이다. 꽤 가까우니 벌점이 작다. 실제값이 0.7인데 0.2라고 예측했다면, 손실은 (0.2 - 0.7)² = 0.25다. 많이 틀렸으니 벌점이 크다. 제곱을 하는 이유가 두 가지 있다. 첫째, 오차의 방향(높게 틀렸든 낮게 틀렸든)을 무시하고 크기만 본다. 0.1 차이든 -0.1 차이든 똑같이 0.01의 손실이다. 둘째, 큰 오차에 불균형적으로 큰 벌점을 준다. 0.1 차이의 손실은 0.01인데, 0.5 차이의 손실은 0.25로 25배나 크다. 이 성질 덕분에 모델은 크게 틀리는 걸 특히 싫어하게 되고, 극단적인 오류를 줄이는 방향으로 학습한다. 왜 이진 교차 엔트로피 대신 L2를 쓰느냐? 이진 교차 엔트로피는 본질적으로 "두 확률 분포가 얼마나 다른가"를 재는 것이다. 정답이 0 아니면 1인 상황에 맞게 설계되어 있다. 정답이 0.43 같은 중간값인 회귀 문제에서는 의미가 없다. L2Loss는 "두 숫자가 얼마나 떨어져 있는가"를 직접 재니까 연속값 예측에 자연스럽다. 분류에서는 ROC-AUC로 평가했다. 회귀에서는 피어슨 상관계수 r을 쓴다. 피어슨 상관계수가 측정하는 건 "모델의 예측값과 실제값이 함께 움직이는 정도"다. 실제 효율이 높은 siRNA에 대해 모델도 높은 값을 예측하고, 실제 효율이 낮은 siRNA에 대해 모델도 낮은 값을 예측한다면, 두 값 사이에 강한 양의 상관관계가 있는 것이다. r이 1이면 완벽한 양의 상관이다. 예측값과 실제값을 그래프에 점으로 찍으면 모든 점이 하나의 직선 위에 올라간다. r이 0이면 아무 관계가 없다. 점들이 사방에 흩어져 있어서 예측이 무의미하다는 뜻이다. r이 -1이면 완벽한 음의 상관, 즉 모델이 체계적으로 반대로 예측하고 있다는 뜻이다. 피어슨 상관계수의 좋은 점은 스케일에 민감하지 않다는 것이다. 모델이 실제값보다 전체적으로 0.1씩 높게 예측하더라도, 높고 낮음의 순서가 맞으면 r은 여전히 높다. 이건 "예측의 절대적 정확도"보다 "상대적 순위를 얼마나 잘 잡는가"에 더 관심이 있을 때 유용하다. siRNA를 설계할 때도 정확한 효율 수치보다는 "어떤 서열이 더 효과적인가"라는 순위가 더 중요한 경우가 많으므로, 이 지표가 실용적으로 의미 있다. 정리 21글자짜리 siRNA 서열을 원-핫 인코딩으로 (21, 4) 행렬로 바꾸고, 두 겹의 Conv1D가 서열을 훑으며 효율에 영향을 미치는 패턴을 추출하고, Flatten과 Dense가 그 패턴들을 종합해서 0~1 사이의 효율 예측값 하나를 내놓는다. L2Loss가 예측과 실제의 차이를 재서 모델을 교정하고, 피어슨 상관계수가 전반적인 예측 품질을 평가한다. 이전 모델들과 뼈대는 같다 Conv1D로 서열 패턴을 찾고, Dense로 최종 판단을 내리는 구조인데, 바뀐 건 문제의 성격(분류에서 회귀로)에 맞춰 모델 크기, 손실 함수, 평가 지표를 조정하였다. 같은 도구를 다른 작업에 맞게 튜닝하는 것이 딥러닝 실무의 핵심이다.
2026-02-27 ⋯ Conv1D DNA 분석 #3 크로마틴 접근성 추가
이전 모델에서 우리는 DNA 서열 101글자만 보고 JUND가 붙을지 말지를 예측했고 꽤 잘 작동했다. 그런데 생물학의 현실은 좀 더 복잡한데 JUND가 좋아하는 서열 패턴(TGACTCA 같은 모티프)이 거기 있어도, 그 DNA 구간이 물리적으로 접근 불가능한 상태라면 JUND는 절대 결합할 수 없다. 우리 세포 안의 DNA는 그냥 풀어져서 떠다니는 게 아니라 히스톤이라는 작은 단백질 뭉치에 실타래처럼 감겨 있고 이렇게 DNA가 히스톤에 감긴 구조를 크로마틴이라고 부른다. DNA가 히스톤에 빽빽하게 감긴 구간에서는 전사인자가 DNA에 접근할 수 없고, 느슨하게 풀린 구간에서는 전사인자가 자유롭게 달라붙을 수 있다. 생물학자들은 이 "얼마나 풀려 있는가"를 실험으로 측정할 수 있다. ATAC-seq 같은 기술을 쓰면 게놈의 각 구간이 얼마나 열려 있는지를 숫자로 얻을 수 있고 이 숫자가 바로 크로마틴 접근성이다. 숫자가 크면 활짝 열려 있는 거고, 작으면 꽉 닫혀 있는 거다. accessibility.txt 파일을 보면 chr22:20208963-20209064에 대해 0.003902 같은 값이 적혀 있다. 이건 "22번 염색체의 이 구간은 접근성이 0.003902다"라는 뜻이다. 각 DNA 조각마다 이 숫자가 하나씩 딸려 있는 것이다. 모델 구조: 두 개의 입력 이전 모델의 입력은 DNA 서열 하나뿐이었다. 이제는 두 가지 정보를 동시에 모델에 넣어야 하는데 DNA 서열 (101, 4) 행렬과, 크로마틴 접근성이라는 숫자 하나이다. DNA 서열 쪽 처리는 이전과 완전히 동일하다. 세 겹의 Conv1D가 서열을 훑으면서 모티프 패턴을 추출하고, 각 레이어 뒤에 Dropout이 과적합을 방지한다. 여기까지는 바뀐 게 없다. 세 겹을 다 통과하면 (101, 15) 형태의 특징 맵이 나오고, Flatten이 이걸 1,515개짜리 1차원 벡터로 펼친다. 이전 모델에서는 이 1,515개 벡터가 곧바로 Dense(1)로 들어가서 최종 예측을 만들었다. 하지만 이번에는 Concatenate라는 연산이 들어간다. Concatenate는 이름 그대로 "이어붙이기"인데 1,515개짜리 벡터의 끝에 접근성 숫자 1개를 붙인다. 그러면 1,516개짜리 벡터가 된다. 앞쪽 1,515개는 "이 서열에 어떤 패턴들이 있는가"라는 정보이고, 맨 마지막 1개는 "이 구간의 크로마틴이 얼마나 열려 있는가"라는 정보다. 이 1,516개짜리 벡터가 Dense(1) 레이어로 들어간다. Dense 레이어는 1,516개 숫자 각각에 가중치를 곱하고 다 더해서 logit 하나를 만든다. 이때 접근성에 해당하는 가중치도 학습된다. 여기서 Concatenate의 위치가 핵심이다. 접근성 정보를 맨 처음에 서열과 함께 넣어버리는 게 아니라, Conv1D가 서열 분석을 다 끝낸 뒤에 합류시킨다. 왜 이렇게 설계했을까? Conv1D의 역할은 DNA 서열에서 모티프 패턴을 찾는 것이다. 크로마틴 접근성은 서열 패턴과는 전혀 다른 종류의 정보다. 이 둘을 처음부터 섞어버리면 Conv1D 필터가 혼란스러워질 수 있다. "나는 서열 패턴만 찾으면 되는 건데, 이 이상한 숫자는 뭐지?" 하게 되는 거다.
대신 Conv1D한테는 순수하게 서열 분석만 시키고, 그 분석 결과를 다 내놓은 뒤에 접근성 정보와 합친다. 그러면 최종 Dense 레이어가 두 정보를 종합해서 판단할 수 있다. "서열을 보니 JUND가 좋아할 만한 모티프가 확실히 있다. 그런데 접근성이 거의 0이네. 그러면 결합 못 하겠다." 이런 논리를 학습할 수 있는 구조가 된다. 커스텀 배치 생성기 이전 모델에서는 DeepChem의 model.fit(train)을 호출하면 알아서 데이터를 배치로 나눠서 모델에 넣어줬다. 그런데 그 표준 파이프라인은 입력이 하나라고 가정하는데 우리 모델은 이제 입력이 두 개(DNA 서열과 접근성)여서 표준 도구로는 접근성 데이터를 끼워넣을 방법이 없다. 그래서 우리가 직접 배치를 만들어주는 generate_batches 함수를 생성한다. 먼저 접근성 데이터를 파이썬 딕셔너리로 올린다. accessibility.txt 파일을 한 줄씩 읽어서 "chr22:20208963-20209064" 같은 ID를 키(key)로, 0.003902 같은 접근성 값을 값(value)으로 저장한다. 이제 ID만 알면 접근성을 바로 찾을 수 있는 전화번호부가 만들어진 셈이다. generate_batches 함수는 파이썬의 제너레이터다. 제너레이터는 일반 함수와 달리 return 대신 yield를 쓴다. 함수를 호출하면 값을 하나 내놓고 멈추고, 다음에 다시 호출하면 멈춘 데서 이어서 다음 값을 내놓는다. 자판기에 동전을 넣을 때마다 음료 하나씩 나오는 것과 비슷하다. 이 제너레이터가 하는 일을 단계별로 따라가보자. dataset.iterbatches(batch_size=1000)가 훈련 데이터에서 1,000개씩 뽑아서 X(서열 행렬들), y(정답들), w(가중치들), ids(ID 문자열들)를 내놓는다. 이때 ids가 핵심이다. ids에는 "chr22:20208963-20209064" 같은 문자열이 1,000개 들어 있다. 이 ID들로 아까 만든 딕셔너리를 조회하면 해당 배치의 접근성 값 1,000개를 얻을 수 있다. 그러면 yield로 내보내는 건 이런 구조다. 첫 번째 원소가 [X, accessibility_array]로, 두 개의 입력을 리스트로 묶은 것이다. X는 (1000, 101, 4) 형태의 서열 배치이고, accessibility_array는 (1000, 1) 형태의 접근성 배치다. 두 번째 원소가 [y]로 정답 레이블이고, 세 번째가 [w]로 샘플 가중치다. Keras 모델은 이 구조를 받아서 첫 번째 입력(features)에 X를, 두 번째 입력(accessibility)에 접근성 배열을 자동으로 연결한다. 배치를 제너레이터로 만들었으니, 학습과 평가도 제너레이터 전용 함수를 써야 한다. 이전에 model.fit()을 쓰던 자리에 model.fit_generator()를, model.evaluate() 자리에 model.evaluate_generator()를 쓴다. 학습 루프의 구조는 이전과 같다. 바깥 루프 20번, 안쪽 10 에포크씩 총 200 에포크를 돈다. 매 10 에포크마다 generate_batches(train, 1)과 generate_batches(valid, 1)로 각각 한 에포크 분량의 배치를 만들어서 훈련 세트와 검증 세트의 ROC-AUC를 출력한다. generate_batches에 epochs 매개변수가 있는 이유는, fit_generator에 10 에포크를 시키려면 제너레이터가 10 에포크 분량의 배치를 내놓아야 한다. 그래서 generate_batches 내부에서 for epoch in range(epochs) 루프를 돌며 같은 데이터를 여러 번 반복해서 내보낸다. evaluate_generator에는 1 에포크만 필요하니 epochs=1을 넘긴다. 정리 이전 모델은 DNA 서열이라는 단일 증거만으로 판단했다. 이번 모델은 서열 분석 결과에 크로마틴 접근성이라는 물리적 맥락 정보를 한 조각 더해준다. Conv1D 세 겹이 서열에서 1,515개의 패턴 특징을 뽑아내면, 거기에 접근성 숫자 1개를 이어붙여 1,516개짜리 벡터를 만들고, Dense 레이어가 이 모든 정보를 종합해서 최종 결합 확률을 내놓는다. 추가된 정보는 숫자 하나에 불과하지만, 그 하나가 "문이 열려 있는가?"라는 결정적인 질문에 대한 답이기 때문에 예측 성능에 의미 있는 차이를 만들어낸다. 아무리 완벽한 결합 모티프가 있어도 크로마틴이 닫혀 있으면 소용없다는 생물학적 현실을, 모델 구조에 직접 반영한 것이다.