Deep Learning
#
2026-02-28 ⋯ VAE 기반 신약 분자 생성 #3 AspuruGuzikAutoEncoder 모델 구성
3단계: AspuruGuzikAutoEncoder 모델 구성 (`vae.py` 20~25행) AspuruGuzikAutoEncoder 내부 구조 이 모델은 2018년 Aspuru-Guzik 연구실의 논문
*"Automatic Chemical Design Using a Data-Driven Continuous Representation of Molecules"*
에서 제안한 아키텍처다. **GRU (Gated Recurrent Unit) 직관**: SMILES는 순서가 있는 시퀀스다. `CC(=O)O`에서 `(`는 그 앞의 `C`에 가지가 생긴다는 의미다.
GRU는 시퀀스를 순서대로 처리하며 **이전 문맥을 기억**한다. **잠재 벡터 차원 = 196**: 196은 이 논문에서 선택한 값이다. 수만 개 분자의 구조 정보를 196개 실수로 압축한다. --- ExponentialDecay 학습률 매 에포크마다 학습률을 0.95배 감소: `batches_per_epoch`를 decay_steps로 사용 → 정확히 에포크 단위로 감소.
2026-02-28 ⋯ VAE 기반 신약 분자 생성 #2 데이터 로드 및 토큰화
MUV 데이터셋 가져오기 DeepChem 라이브러리로 MUV 데이터셋을 자동으로 다운로드해준다. MUV는 Maximum Unbiased Validation의 약자로, 신약 후보 물질을 가상으로 스크리닝하는 벤치마크 데이터셋이다. 수만 개의 분자가 SMILES 문자열 형태로 들어 있다. DNA 데이터에서는 .ids는 샘플의 식별자였는데 chr22:20208963-20209064 같은 게놈 좌표가 ID였다. 그런데 분자 데이터에서는 SMILES 문자열 자체가 ID다. 왜냐하면 SMILES가 곧 분자의 고유한 이름이기 때문이다. CCO라고 쓰면 에탄올이고, 다른 어떤 분자도 CCO가 아니다. 그래서 train_dataset.ids를 가져오면 곧바로 학습용 SMILES 문자열 목록을 얻게 된다. load_muv()가 데이터를 훈련, 검증, 테스트 세 세트로 나눠서 돌려주는 건 이전 챕터들과 동일한 패턴이다. 학습에는 훈련 세트를 쓰고, 하이퍼파라미터 조정에는 검증 세트를, 최종 성능 보고에는 테스트 세트를 쓴다. 토큰화: 분자 문자열을 모델이 이해할 수 있는 조각으로 나누기 SMILES 문자열을 신경망에 넣으려면 먼저 문자열을 숫자로 바꿔야 한다. 그 첫 번째 단계가 토큰화다. 어떤 "조각"들이 이 데이터에 존재하는지를 파악하는 과정이다. 이 분석에서는 문자 수준 토큰화를 쓴다. SMILES 문자열을 한 글자 한 글자로 쪼개는 것이다. CC(=O)O라는 문자열은 C, C, (, =, O, ), O라는 일곱 개의 토큰으로 분해된다. 빈 집합(set)을 하나 만들고, 모든 학습 SMILES를 하나씩 순회한다. 각 SMILES를 set()으로 감싸면 그 문자열에 등장하는 고유한 문자들의 집합이 나온다. CC(=O)O의 경우 {C, (, =, O, )}가 된다. 이걸 전체 토큰 집합에 union(합집합)으로 추가한다. 수만 개의 SMILES를 다 돌고 나면, 이 데이터셋에 등장하는 모든 고유한 문자가 하나의 집합에 모인다. 이 집합에는 어떤 것들이 들어 있을까? 알파벳 대문자 C, N, O, S 같은 원소 기호가 있고, 소문자 c, n, o, s 같은 방향족 원소 기호가 있고, 괄호, 등호, 우물정자, 슬래시, 숫자 등 SMILES 문법에 쓰이는 특수 문자들이 있다. 이 모든 고유 문자가 모델의 "어휘(vocabulary)"가 된다. 모델이 아는 글자의 전체 목록인 셈이다. sorted()로 이 토큰들을 정렬하는 이유는 재현 가능성 때문이다. 파이썬의 집합(set)은 순서가 없어서 실행할 때마다 원소의 순서가 바뀔 수 있다. 정렬하면 항상 같은 순서가 보장되므로, 같은 코드를 다시 돌려도 같은 결과를 얻을 수 있다. 토큰의 순서가 중요한 이유는 나중에 각 토큰에 번호를 매겨서 원-핫 인코딩을 할 것이기 때문이다. 순서가 바뀌면 같은 문자에 다른 번호가 매겨져서 모델이 혼란에 빠진다. 최대 길이: 왜 가장 긴 문자열의 길이를 알아야 하는가 마지막으로 모든 학습 SMILES 중 가장 긴 것의 길이를 구한다. 이건 신경망의 근본적인 제약 때문이다. 신경망은 고정된 크기의 입력을 기대한다. 배치 안의 모든 데이터가 같은 shape이어야 행렬 연산이 가능하다. 그런데 SMILES 문자열의 길이는 분자마다 다르다. 에탄올은 CCO로 3글자이고, 복잡한 약물 분자는 100글자가 넘을 수도 있다. 이걸 하나의 배치에 넣으려면 모든 문자열의 길이를 통일해야 한다. 가장 긴 문자열의 길이에 맞춰서 짧은 문자열 뒤에 빈칸(패딩)을 채우는 것이다. DNA 분석에서는 모든 서열이 정확히 101bp 또는 21bp로 고정되어 있어서 이 문제가 없었다. 하지만 SMILES는 분자 구조에 따라 길이가 천차만별이므로 최대 길이를 알아내서 패딩 기준으로 쓰는 과정이 반드시 필요하다. 정리 아직 모델을 만들지도 않았는데 벌써 중요한 준비 작업 세 가지를 마쳤다. 첫째, 수만 개의 분자를 SMILES 문자열로 확보했다. 둘째, 이 문자열들에 등장하는 모든 고유 문자를 모아서 모델의 어휘를 정의했다. 셋째, 가장 긴 문자열의 길이를 파악해서 입력 크기의 기준을 잡았다. 이 세 가지 정보, 즉 SMILES 목록과 토큰 어휘와 최대 길이가 있으면, 다음 단계에서 각 SMILES를 원-핫 인코딩으로 변환해서 VAE에 넣을 준비가 완료된다. DNA 서열을 (101, 4) 행렬로 변환했던 것처럼, SMILES도 (최대길이, 어휘크기) 행렬로 변환될 것이다. 표현 대상이 DNA에서 분자로 바뀌었을 뿐, "문자열을 숫자 행렬로 바꿔서 신경망에 넣는다"는 근본 전략은 동일하다.
2026-02-28 ⋯ VAE 기반 신약 분자 생성 #1 VAE와 KL 발산
약을 만드는 과정의 첫 번째 관문은 "어떤 분자를 약으로 쓸 것인가"를 정하는 것이다. 자연에 존재하는 분자 중에서 고르는 것도 있지만, 완전히 새로운 분자를 설계하는 것이 현대 신약 개발의 핵심이다. 그런데 여기서 문제의 규모를 실감해야 한다. 약이 될 수 있는 분자의 종류가 대략 10의 60제곱 개 이상이라고 추정된다. 이게 얼마나 큰 수인지 감을 잡기 위해 비교하면, 우주에 존재하는 원자의 수가 약 10의 80제곱 개다. 가능한 약물 분자의 수는 우주의 원자 수에 견줄 만큼 거대하다. 이 광활한 공간을 하나하나 탐색하는 건 당연히 불가능하다. 그렇다면 영리한 방법이 필요하다. 이미 약으로 알려진 분자들의 패턴을 학습해서, 그 패턴에 부합하는 새로운 분자를 자동으로 만들어내면 어떨까? 이것이 바로 이 챕터에서 하려는 일이다. 기존 약물 분자들을 딥러닝 모델에 학습시키고, 그 모델이 학습한 패턴을 바탕으로 아직 세상에 없었던 새로운 분자를 생성하는 것이다. | 단계 | 내용 |
|---|---|
| 데이터 | MUV 데이터셋에서 SMILES 문자열 로드 |
| 전처리 | 문자 토큰화 + 최대 길이 계산 |
| 모델 | VAE (Variational Autoencoder): SMILES → 잠재 벡터 → SMILES |
| 학습 | 시퀀스-투-시퀀스 방식 |
| 생성 | 랜덤 잠재 벡터 → 새 분자 디코딩 → RDKit 유효성 검증 | SMILES: 분자를 글자로 적는 법 딥러닝 모델에 분자를 넣으려면 분자를 컴퓨터가 다룰 수 있는 형태로 표현해야 한다. 분자는 원래 3차원 공간에서 원자들이 결합으로 연결된 구조인데, 이걸 어떻게 텍스트로 바꿀 수 있을까? SMILES라는 표기법이 바로 그 해결책이다. 분자 구조를 아스키 문자열로 변환하는 규칙이다. 예를 들어 에탄올(술의 주성분)은 탄소 두 개에 산소 하나가 붙어 있는 간단한 분자인데, SMILES로 쓰면 CCO라는 세 글자가 된다. C는 탄소, O는 산소다. 아스피린은 CC(=O)Oc1ccccc1C(=O)O라는 좀 더 복잡한 문자열이 된다. 괄호는 분자의 가지(곁사슬)를 나타내고, 소문자 c는 방향족 탄소(벤젠 고리 같은 구조)를 나타내고, 등호는 이중결합을 나타내고, 숫자는 고리가 닫히는 위치를 표시한다. 이 표기법의 천재적인 점은 분자라는 2차원/3차원 구조를 1차원 문자열로 변환한다는 것이다. 문자열이 되는 순간, 자연어 처리(NLP)에서 발전해온 모든 기법을 그대로 갖다 쓸 수 있다. DNA 서열을 Conv1D로 분석했던 것과 마찬가지로, SMILES 문자열도 시퀀스 모델로 다룰 수 있다. 분자 설계 문제가 텍스트 생성 문제로 바뀌는 것이다. 오토인코더와 VAE VAE를 이해하려면 먼저 오토인코더라는 더 단순한 구조부터 이해해야 한다.
오토인코더의 아이디어는 놀라울 정도로 간단하다. 입력을 아주 작은 벡터로 압축한 다음, 그 작은 벡터에서 다시 원래 입력을 복원하도록 학습하는 것이다. 마치 긴 소설을 한 문단 요약으로 압축했다가, 그 요약만 보고 원래 소설을 다시 쓰는 것과 비슷하다. 구체적으로 보면 인코더와 디코더 두 부분으로 나뉜다. 인코더는 SMILES 문자열 같은 고차원 입력을 받아서 z라는 저차원 벡터(잠재 벡터, latent vector)로 압축한다. 디코더는 이 z를 받아서 원래 입력을 복원한다. 학습 목표는 "입력과 복원된 출력이 최대한 비슷해야 한다"는 것이다.
이 과정에서 잠재 벡터 z는 입력의 핵심 정보만 담고 있어야 한다. 입력이 100차원인데 z가 10차원이라면, 100개의 정보를 10개로 압축해야 하니까 정말 중요한 특징만 남길 수밖에 없다. 이 z가 입력 데이터의 본질적인 "요약"이 되는 것이다.
분자의 맥락에서 말하면, 수십 글자의 SMILES 문자열이 수십 차원의 잠재 벡터로 압축된다. 이 벡터의 각 차원이 분자의 어떤 추상적인 특성(크기, 소수성, 고리 구조의 복잡도 등)을 포착하게 된다. 오토인코더로 분자를 압축하고 복원하는 건 잘 된다. 하지만 우리의 진짜 목표는 "새로운 분자를 생성하는 것"이다. 기존 분자를 복원하는 게 아니라, 아직 존재하지 않는 분자를 만들어내고 싶다.
자연스러운 생각은 이거다. 잠재 공간에서 아무 점이나 찍고, 디코더에 넣으면 새로운 분자가 나오지 않을까?
여기서 일반 오토인코더의 근본적인 문제가 드러난다. 오토인코더의 잠재 공간은 불규칙하다. 학습 데이터의 각 분자가 잠재 공간의 특정 점에 매핑되는데, 이 점들이 고르게 분포하지 않는다. 어떤 영역에는 점들이 빽빽하게 몰려 있고, 어떤 영역은 텅 비어 있다. 비어 있는 영역에서 점을 찍어서 디코더에 넣으면 어떻게 될까? 디코더는 그 영역에서 한 번도 학습한 적이 없으니, 무의미한 쓰레기를 내놓는다. 유효한 분자가 아닌 문자열의 난잡한 조합이 나오는 것이다. 잠재 공간의 빈 곳은 "디코더가 모르는 미지의 영역"인 셈이다. 즉 일반 오토인코더는 훌륭한 압축기이지만 형편없는 생성기다. 변분 오토인코더(VAE)는 잠재 벡터를 하나의 고정된 점이 아니라 확률 분포로 표현함으로써 이를 해결한다. 일반 오토인코더에서 인코더는 입력을 받아서 "이 분자의 잠재 벡터는 [1.3, -0.7, 2.1]이다"라고 하나의 점을 찍었다. VAE의 인코더는 대신 "이 분자의 잠재 벡터는 평균이 [1.3, -0.7, 2.1]이고 분산이 [0.2, 0.5, 0.1]인 정규분포를 따른다"라고 분포를 내놓는다. 즉 인코더가 μ(평균)와 σ²(분산) 두 벡터를 출력하는 것이다. 그 다음, 이 분포에서 실제 잠재 벡터 z를 하나 샘플링한다. z는 매번 약간씩 다른 값이 나온다. 같은 분자를 넣어도 z가 조금씩 흔들리는 것이다. 디코더는 이 흔들리는 z를 받아서도 원래 분자를 복원해야 한다. 이것만으로도 잠재 공간이 더 부드러워진다. z가 매번 정확히 같은 점이 아니라 주변의 약간 다른 점에서도 올 수 있으니, 디코더는 특정 점 하나에만 적응하는 게 아니라 그 주변 영역 전체에서 올바른 출력을 내놓도록 학습된다. 잠재 공간에 빈틈이 줄어드는 것이다. KL 발산 하지만 여기서 한 가지가 더 필요하다. 각 분자가 만들어내는 분포가 제각각이면, 여전히 잠재 공간에 빈 영역이 생길 수 있다. 어떤 분자의 분포는 잠재 공간의 한쪽 구석에 있고, 다른 분자의 분포는 반대쪽 구석에 있으면, 그 사이는 여전히 비어 있다. 이걸 해결하는 것이 KL 발산(Kullback-Leibler divergence)이라는 항이다. VAE의 손실 함수는 두 부분으로 이루어진다. 재구성 손실과 KL 발산이다. 재구성 손실은 익숙하다. 입력과 복원된 출력이 얼마나 다른가를 재는 것이다. 이전 챕터들에서 봤던 cross-entropy와 본질적으로 같다. KL 발산은 새로 등장하는 것인데, "인코더가 내놓는 분포 N(μ, σ²)가 표준 정규분포 N(0, 1)에서 얼마나 벗어나 있는가"를 재는 것이다. 이 항이 손실에 포함되면 모델은 인코더의 출력 분포를 표준 정규분포에 가깝게 만들라는 압력을 받는다. 구체적으로 무슨 일이 벌어지는지 보자. μ가 0에서 멀어지면 KL 발산이 커진다. 그러니 모델은 μ를 0 근처로 모으려 한다. σ가 1에서 벗어나면(너무 크거나 너무 작으면) 역시 KL 발산이 커진다. 그러니 σ를 1 근처로 유지하려 한다. 결과적으로 모든 분자의 잠재 분포가 원점 근처에 모이고, 퍼짐이 비슷해진다. 잠재 공간 전체가 표준 정규분포처럼 균일하고 연속적으로 채워지는 것이다. 이제 잠재 공간의 어느 점을 찍어도 그 근처에 의미 있는 분자가 있다. 빈 공간이 사라진 것이다. 물론 재구성 손실과 KL 발산 사이에는 긴장이 있다. 재구성 손실만 줄이려면 각 분자에 고유한 위치를 부여해서 디코더가 헷갈리지 않게 하는 게 좋다. KL 발산만 줄이려면 모든 분자의 분포를 똑같이 N(0,1)로 만들어야 하는데, 그러면 디코더가 아무것도 구분할 수 없다. VAE는 이 두 힘 사이에서 균형을 찾아야 한다. "충분히 잘 복원하면서도 잠재 공간은 충분히 규칙적인" 최적의 지점을 찾는 것이다. 재파라미터화 트릭 (Reparameterization Trick) 여기서 기술적으로 중요한 문제가 하나 있다. 인코더가 μ와 σ를 내놓고, 거기서 z를 샘플링한다고 했다. 그런데 "샘플링"이라는 연산은 랜덤이다. 주사위를 굴리는 것과 같다. 신경망을 학습시키려면 역전파를 해야 하는데, 역전파는 출력에서 입력 방향으로 그래디언트를 계산하는 과정이다. 랜덤 연산은 미분할 수가 없다. "주사위 결과를 μ에 대해 미분하라"는 건 수학적으로 의미가 없다. 재파라미터화 트릭은 z = μ + ε × σ로 쓰는 것이다. 여기서 ε은 표준 정규분포 N(0,1)에서 샘플링한 값이다. 이 두 식이 수학적으로 동일하다는 걸 확인해보자. z가 N(μ, σ²)를 따른다는 건 z = μ + σ × (어떤 표준 정규 변수)라는 뜻이다. 그 표준 정규 변수를 ε이라고 부른 것뿐이다. 결과는 완전히 같다. 하지만 그래디언트 계산 관점에서는 완전히 달라진다. 원래 식에서 z는 μ와 σ에 의존하는 랜덤 변수였다. 랜덤성과 파라미터 의존성이 뒤섞여 있어서 미분이 안 됐다. 재파라미터화 후에는 ε이 모든 랜덤성을 담당하고, ε은 모델의 파라미터와 아무 관계가 없다. z = μ + ε × σ에서 ε은 그냥 상수처럼 취급할 수 있다. 그러면 z를 μ에 대해 미분하면 1이고, σ에 대해 미분하면 ε이다. 깔끔하게 미분이 된다. 비유하자면 이렇다. 랜덤성이라는 불확실한 요소를 네트워크 밖으로 빼내서 "외부에서 주입되는 잡음"으로 만든 것이다. 네트워크 내부의 연산은 모두 결정적(deterministic)이고 미분 가능하다. 랜덤 잡음 ε은 밖에서 던져주는 주사위일 뿐이고, 네트워크는 그 주사위 결과에 μ와 σ로 변환을 가하는 것이다. 이 간단한 수학적 트릭 하나가 VAE의 학습을 가능하게 만드는 결정적인 열쇠다. 모델이 학습을 마치면 생성 단계는 간단하다. 표준 정규분포 N(0,1)에서 잠재 벡터 z를 하나 랜덤으로 뽑는다. 이 z를 디코더에 넣으면 SMILES 문자열이 한 글자씩 생성된다. KL 발산 덕분에 잠재 공간이 표준 정규분포처럼 균일하게 채워져 있으므로, 아무 데서나 뽑은 z도 의미 있는 분자에 대응할 가능성이 높다.
생성된 SMILES가 실제로 유효한 분자인지는 RDKit이라는 화학 라이브러리로 검증한다. SMILES 규칙을 만족하는 문자열이라도 화학적으로 불가능한 구조(원자가가 맞지 않는다든지)일 수 있기 때문이다. 유효성 검증을 통과한 분자만이 실제 신약 후보로 고려될 수 있다. 정리 VAE 기반 신약 분자 생성 파이프라인은 다음과 같다. MUV 데이터셋에서 기존 약물 분자들의 SMILES를 가져온다. 이 문자열들을 VAE에 학습시킨다. 인코더가 각 분자를 확률 분포로 압축하고, 거기서 샘플링한 잠재 벡터를 디코더가 다시 원래 분자로 복원하도록 학습한다. 재구성 손실이 복원 품질을 보장하고, KL 발산이 잠재 공간의 규칙성을 보장한다. 학습이 끝나면 잠재 공간에서 랜덤하게 점을 찍고 디코더로 새 분자를 만들어낸다. 이전 챕터들에서는 딥러닝으로 "예측"을 했다. DNA 서열을 보고 결합 여부를 예측하고, 망막 사진을 보고 등급을 예측했다. 이 챕터에서는 딥러닝으로 "생성"을 한다. 기존 데이터의 패턴을 학습해서 완전히 새로운 것을 만들어내는 것이다.
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차원 이미지로 바뀌었고, 문제가 이진 분류나 회귀에서 순서형 다중 분류로 바뀌었지만, "입력을 숫자로 바꾸고 합성곱으로 패턴을 찾아서 판단을 내린다"는 근본 구조는 동일하다.