Deep Learning
#
2026-03-01 ⋯ BERT 뉴스 분류 #4 Label Smoothing, 학습률 스케줄링, Early Stopping
Label Smoothing 일반적인 분류 학습에서 정답 레이블은 원-핫 벡터다. Business 기사라면 [0, 0, 1, 0]이다. 이건 모델에게 "Business일 확률이 정확히 100%이고 나머지는 정확히 0%가 되어야 한다"고 요구하는 것이다. 이 요구가 왜 문제가 되는지 생각해보자. 모델이 Business에 99%를 부여해도 [0, 0, 1, 0]과는 여전히 차이가 있으니 손실이 남아 있다. 모델은 이 잔여 손실을 줄이려고 logit을 점점 더 극단적으로 만든다. Business의 logit을 8, 10, 15로 키우고 나머지를 -5, -10으로 밀어낸다. Softmax를 통과하면 [0.00, 0.00, 1.00, 0.00]에 수렴한다. 수학적으로는 정답에 가까워지지만, 이 과정에서 모델은 훈련 데이터에 과도하게 확신을 갖게 된다. 새로운 데이터에서 애매한 기사가 나왔을 때 "99.9% Business"라고 자신만만하게 틀리는 모델이 되는 것이다. Label Smoothing은 이 문제를 해결한다. 정답 레이블을 살짝 "부드럽게" 만든다. ε=0.1로 설정하면 정답 클래스의 목표가 1.0에서 0.925로 낮아지고, 나머지 클래스의 목표가 0에서 0.025로 올라간다. [0, 0, 1, 0]이 [0.025, 0.025, 0.925, 0.025]가 되는 것이다. 이게 어떤 효과를 만드는가? 모델이 Business에 0.925 이상의 확률을 내놓으면 더 이상 보상이 없다. 목표 자체가 0.925이니까. logit을 극단적으로 키워봐야 손실이 줄어들지 않는다. 모델은 자연스럽게 적당한 수준의 확신에서 멈추게 된다. 이전 분석에서 본 정규화 기법들과 비교하면 이렇다. Dropout은 뉴런을 랜덤으로 꺼서 특정 뉴런에 대한 과도한 의존을 막았다. L2 정규화는 가중치가 커지는 것에 벌점을 줘서 모델의 복잡도를 제한했다. Label Smoothing은 출력의 확신도에 상한을 둬서 극단적인 예측을 막는다. 세 가지 모두 과적합을 방지하는 정규화 기법이지만 작용하는 지점이 다르다. Dropout은 중간 레이어에서, L2는 가중치에서, Label Smoothing은 출력단에서 작동한다. Warmup + 코사인 스케줄러 지금까지 학습률을 다양한 방식으로 조절하는 걸 봐왔다. VAE에서는 ExponentialDecay로 에포크마다 줄였고, 바로 이전 단계에서는 레이어 깊이에 따라 차등을 뒀다. 이번에는 학습의 시간 흐름에 따른 더 정교한 스케줄링을 본다. 먼저 Warmup이 왜 필요한지를 이해하자. BERT를 파인튜닝할 때 분류 헤드는 랜덤 초기화 상태에서 시작한다. 학습 초반에 이 랜덤한 분류 헤드가 역전파로 내보내는 그래디언트는 엉뚱한 방향을 가리키고 있다. 아직 아무것도 모르는 신입이 지시를 내리는 것과 같다. 이 시점에 학습률이 크면 이 엉뚱한 그래디언트가 BERT 본체의 사전학습 가중치를 크게 흔들어서 손상시킨다. Warmup은 이걸 방지한다. 학습 시작 시 학습률을 0에서 출발해서 점진적으로 올린다. 전체 학습 스텝의 10%를 워밍업에 쓴다고 하면, 처음 10% 동안은 학습률이 0에서 목표값까지 천천히 올라간다. 이 기간 동안 분류 헤드가 어느 정도 합리적인 그래디언트를 만들어낼 수 있는 수준까지 안정화된다. 그 다음에야 본격적인 학습률로 전체 모델을 조정하기 시작한다. 워밍업이 끝나면 학습률을 어떻게 줄여갈 것인가? 여기서는 코사인 감쇠를 사용했다. 선형 감쇠와 코사인 감쇠의 차이는, 선형 감쇠는 학습률이 일정한 속도로 꾸준히 줄어든다. 직선으로 떨어지는 것이다. 단순하고 직관적이지만, 학습 중반부터 이미 학습률이 상당히 작아져서 미세 조정의 여지가 줄어든다. 코사인 감쇠는 다른 형태로 줄어든다. 코사인 곡선의 위쪽 절반을 떠올려보자. 처음에는 거의 수평에 가깝게 천천히 줄어들다가, 중반부에 약간 가팔라지고, 끝으로 갈수록 다시 완만해진다. 핵심적인 차이는 중반부에 있다. 선형 감쇠는 중반에 이미 학습률이 절반으로 떨어지지만, 코사인 감쇠는 중반에서도 상대적으로 높은 학습률을 유지한다. 이 덕분에 학습 중반까지 적극적인 학습이 지속되고, 후반부에서도 완전히 0에 수렴하기보다 약간의 학습률이 남아서 미세 조정이 계속된다. 파인튜닝에서 코사인 감쇠가 선호되는 이유가 여기 있다. 파인튜닝은 이미 좋은 가중치를 아주 살짝 조정하는 작업이다. 학습 후반부의 미세 조정이 특히 중요한데, 코사인 감쇠는 이 후반부에서 학습률이 너무 빨리 사라지지 않게 해준다. Early Stopping — 과적합 자동 감지 이전 DNA 분석 챕터에서 매 10 에포크마다 훈련 성능과 검증 성능을 출력해서 사람이 직접 과적합 여부를 확인했다. 훈련 성능은 계속 오르는데 검증 성능이 정체되거나 떨어지면 과적합이 시작된 것이고, 학습을 멈춰야 한다. Early Stopping은 이 판단을 자동화한다. 200 스텝마다 검증 세트의 F1을 계산하고, 이전 최고 기록과 비교한다. 개선되면 그 시점의 모델을 저장한다. 개선되지 않으면 카운터가 올라간다. patience=3이니까 3번 연속 개선이 없으면 "이제 더 이상 나아지지 않는다"고 판단하고 학습을 중단한다. 그리고 가장 F1이 높았던 체크포인트의 모델을 복원한다. 이 메커니즘의 아름다운 점은 에포크 수를 미리 정확히 결정하지 않아도 된다는 것이다. "10 에포크면 될까? 20 에포크면 될까?"라는 질문에 답할 필요가 없다. 그냥 넉넉하게 잡아놓으면 모델이 알아서 최적의 시점에 멈춘다. 너무 일찍 멈추면 학습이 부족하고, 너무 늦게 멈추면 과적합이 생기는데, Early Stopping이 그 사이의 최적점을 자동으로 찾아준다. evaluation_strategy="steps"와 eval_steps=200은 에포크 단위가 아닌 스텝 단위로 평가한다는 뜻이다. 12만 건 데이터에 배치 크기 8이면 한 에포크가 15,000 스텝이다. 에포크 단위로 평가하면 과적합이 에포크 중간에 시작되어도 에포크가 끝나야 알 수 있다. 200 스텝마다 평가하면 훨씬 세밀하게 최적점을 잡을 수 있다. 정리 이 네 가지 기법은 모두 같은 질문에 대한 다른 각도의 답이다. "사전학습된 모델을 새로운 과제에 맞게 조정할 때, 기존 지식을 보존하면서 새로운 능력을 추가하려면 어떻게 해야 하는가?" Label Smoothing은 출력의 과신을 막아서 일반화를 돕는다. Warmup과 코사인 스케줄러는 학습의 시간 흐름에 따라 강도를 조절해서 초반의 불안정과 후반의 과적합을 동시에 방지한다. Gradual Unfreezing은 모델의 부분별로 학습 시작 시점을 다르게 해서 안정적인 조정을 보장한다. Early Stopping은 과적합이 시작되는 순간을 자동으로 감지해서 최적의 시점에 멈춘다. 이 모든 것이 합쳐져서, 이미 영어를 이해하는 BERT가 그 이해력을 잃지 않으면서도 뉴스 분류라는 새로운 일을 안정적으로 배울 수 있게 만드는 것이다. cf 레이어 동결, 레이어별 차등학습률, Gradual Unfreezing 셋은 같은 문제("사전학습 레이어를 얼마나, 어떻게 건드릴 것인가")에 대한 서로 다른 전략이다. 동시에 전부 적용하는 게 아니라, 상황에 따라 하나를 선택하거나 두 가지를 조합한다. 레이어 동결은 가장 단순하다. 하위 레이어를 완전히 얼리고 상위 레이어만 학습시킨다. 구현이 쉽고 효과도 분명하지만, 얼리느냐 마느냐의 이진적 선택만 가능하다. 레이어별 차등 학습률은 동결의 연속적 일반화다. 완전히 얼리는 대신 하위 레이어에 아주 작은 학습률을 주고, 상위로 갈수록 키운다. 모든 레이어가 조금씩은 조정되므로 동결보다 유연하지만, 학습률 배치를 설계해야 하는 복잡도가 있다. Gradual Unfreezing은 시간 축을 추가한 전략이다. 처음에는 분류 헤드만 학습하고, 단계적으로 아래 레이어를 풀어간다. 동결 전략을 시간에 따라 변화시키는 것이다. 실무에서는 조합도 가능하다. 예를 들어 Gradual Unfreezing을 하면서 각 단계에서 해동된 레이어들에 차등 학습률을 적용하는 식이다. 하지만 "동결 + 차등 학습률"을 같은 레이어에 동시에 적용하는 건 모순이다. 동결된 레이어는 학습률이 아예 의미가 없으니까. 결국 셋은 복잡도와 유연성의 스펙트럼 위에 있다. 동결이 가장 단순하고, 차등 학습률이 중간이고, Gradual Unfreezing이 가장 정교하다. 데이터가 충분하고 실험 시간이 넉넉하면 정교한 전략을 시도하고, 빠르게 결과를 내야 하면 단순한 동결부터 시작하는 것이 일반적이다.
2026-03-01 ⋯ BERT 뉴스 분류 #3 레이어별 차등 학습률
BERT의 12개 레이어 BERT 안에 Transformer 인코더가 12개 쌓여 있다고 했다. 이 12개 레이어가 전부 똑같은 역할을 하는 걸까? 아니다. 연구자들이 각 레이어가 무엇을 학습하는지 분석해보니, 하위 레이어와 상위 레이어가 포착하는 정보의 종류가 확연히 달랐다. 하위 레이어(0~3번 근처)는 문법적이고 형태적인 기본 지식을 담고 있다. 주어와 동사의 수 일치, 품사 구분, 기본적인 구문 구조 같은 것이다. 이런 지식은 영어라는 언어 자체의 규칙이라서 뉴스 분류를 하든, 감정 분석을 하든, 번역을 하든 보편적으로 필요하다. 상위 레이어(8~11번 근처)는 훨씬 추상적이고 과제에 특화된 정보를 담는다. 문장 전체의 의미, 단어들 사이의 복잡한 문맥 관계, 그리고 특정 과제(우리의 경우 뉴스 카테고리 판단)에 직접적으로 유용한 표현을 만들어낸다.
이 관찰은 Conv1D를 여러 겹 쌓았을 때의 패턴과 놀랍도록 유사하다. DNA 분석에서 첫 번째 Conv1D가 기본 모티프(개별 글자 패턴)를 잡고, 두 번째가 모티프 조합을, 세 번째가 전체적인 구조 패턴을 잡았던 것처럼, BERT의 하위 레이어는 언어의 기본 단위를 처리하고 상위 레이어는 의미의 큰 그림을 처리한다. 즉 하위 레이어의 범용 언어 지식은 뉴스 분류에도 그대로 유용하니까 굳이 바꿀 필요가 없다. 그대로 두고 상위 레이어만 뉴스 분류에 맞게 조정하면 된다. 레이어 동결 이것이 레이어 동결(Layer Freezing)이다. requires_grad = False를 설정하면 해당 파라미터는 학습 과정에서 업데이트되지 않는다. 그래디언트가 계산되지 않으니 값이 변하지 않는다. 사전학습에서 배운 상태 그대로 얼어붙는 것이다.
코드를 보면 임베딩 레이어와 하위 6개 레이어(0~5번)를 동결한다. 전체 1억 1천만 개 파라미터 중 약 5,200만 개가 동결되어 실제로 학습되는 파라미터는 약 6,000만 개로 줄어든다. 거의 절반이 줄어드는 셈이다. 이 전략의 이점은 세 가지다. 첫째, 학습할 파라미터가 줄어드니 같은 양의 데이터로도 과적합이 덜 생긴다. 파라미터가 많을수록 훈련 데이터를 외워버리기 쉬운데, 절반이 고정되어 있으면 그만큼 외울 여지가 줄어든다. 둘째, 그래디언트를 절반의 파라미터에 대해서만 계산하면 되니까 학습 속도가 빨라진다. 셋째, 가장 중요한 이점으로, 사전학습 지식이 보존된다. 하위 레이어의 범용 언어 이해 능력이 파인튜닝 과정에서 손상될 위험이 원천적으로 차단된다. 이걸 DNA 분석과 연결해서 생각하면 흥미로운 대비가 보인다. DNA 모델에서는 "이 문제에 Conv1D 레이어를 몇 겹 쌓을 것인가"를 결정했다. 101bp 서열에는 3겹, 21bp 서열에는 2겹을 골랐다. BERT에서는 레이어가 이미 12개 고정되어 있으니, 대신 "이 12개 중 몇 개를 실제로 학습시킬 것인가"를 결정하는 것이다. 문제의 형태는 다르지만, "모델의 용량을 과제의 복잡도에 맞게 조절한다"는 본질은 동일하다. 레이어별 차등 학습률 (Layer-wise LR Decay) 동결은 효과적이지만 이진적이다. 학습하거나 안 하거나, 둘 중 하나다. 레이어 5는 완전히 얼어 있고 레이어 6은 완전히 풀려 있다. 경계가 칼로 자른 듯 뚜렷하다. 하지만 현실에서 레이어의 역할은 그렇게 깔끔하게 나뉘지 않는다. 레이어 5의 지식도 뉴스 분류에 약간은 조정이 필요할 수 있고, 레이어 6의 지식도 대부분은 보존해야 할 수 있다. "전부 얼리거나 전부 풀거나"보다 더 세밀한 조절이 가능하지 않을까? 차등 학습률(Layer-wise Learning Rate Decay)이 바로 그 해결책이다. 각 레이어에 서로 다른 학습률을 부여하는 것이다. 상위 레이어일수록 학습률이 크고, 하위 레이어로 갈수록 학습률이 작아진다. 완전히 동결하는 대신, 하위 레이어도 아주 살짝은 조정할 여지를 남기되 그 정도를 최소화하는 것이다. 코드를 보면 분류 헤드의 학습률이 3.6e-5로 가장 크다. 이건 랜덤 초기화에서 시작하는 부분이니까 가장 많이 바꿔야 한다. 레이어 11의 학습률은 3.5e-5로 거의 비슷하다. 과제에 가장 특화된 최상위 레이어니까 적극적으로 조정한다. 여기서부터 레이어가 하나 내려갈 때마다 학습률에 0.9를 곱한다. 레이어 10은 3.15e-5, 레이어 9는 2.84e-5, 이런 식으로 줄어든다. 맨 아래 임베딩 레이어에 도달하면 0.99e-5까지 내려간다. 최상위 학습률의 약 4분의 1 수준이다. 0.9를 반복해서 곱하니까 이건 기하급수적 감쇠다. VAE 학습에서 ExponentialDecay로 시간이 지남에 따라 학습률을 줄였던 것과 같은 수학적 구조인데, 적용되는 축이 다르다. 거기서는 "학습 초반에는 크게, 후반에는 작게"라는 시간 축의 감쇠였다. 여기서는 "상위 레이어는 크게, 하위 레이어는 작게"라는 깊이 축의 감쇠다. 이 전략이 직관적으로 왜 합리적인지를 생각해보자. 임베딩 레이어는 각 토큰의 기본 표현을 담고 있다. "the"라는 단어의 벡터 표현은 뉴스 분류를 하든 감정 분석을 하든 거의 같아야 한다. 이걸 크게 바꾸면 BERT가 "the"의 의미를 잊어버리는 것과 같다. 그래서 학습률이 가장 작다. 반면 레이어 11은 입력 문장의 최종적인 의미 표현을 만드는 곳이다. "이 문장이 스포츠에 관한 것인가 경제에 관한 것인가"를 판단하는 데 가장 직접적으로 관여하므로, 뉴스 분류에 맞게 적극적으로 조정해야 한다. 그래서 학습률이 가장 크다. AdamW 옵티마이저에 파라미터 그룹별로 다른 학습률을 지정할 수 있다는 점이 이 구현을 가능하게 한다. 보통은 모델 전체에 하나의 학습률을 쓰지만, PyTorch의 옵티마이저는 파라미터를 그룹으로 나눠서 각 그룹에 다른 학습률을 적용할 수 있다. 이 기능을 활용해서 14개 그룹(분류 헤드 1개 + 레이어 12개 + 임베딩 1개)에 각각 다른 학습률을 배정하는 것이다. 정리 동결과 차등 학습률은 같은 스펙트럼 위에 있다. 동결은 차등 학습률의 극단적인 경우다. 학습률을 0으로 설정하면 동결과 같은 효과가 난다. 차등 학습률은 동결을 일반화한 것으로, 0과 최대값 사이의 연속적인 값을 허용한다. 실무에서는 둘 중 하나를 선택하거나 둘을 조합할 수 있다. 하위 6개 레이어를 완전히 동결하고, 나머지 6개 레이어에만 차등 학습률을 적용하는 것도 가능하다. 어떤 전략이 최적인지는 데이터의 양, 과제의 특성, 사전학습 데이터와 파인튜닝 데이터의 유사도에 따라 달라진다. 뉴스 텍스트는 위키피디아와 상당히 유사하므로 하위 레이어의 지식이 대부분 유효하다. 만약 파인튜닝 데이터가 의학 논문이나 법률 문서처럼 사전학습 데이터와 아주 다른 도메인이라면, 하위 레이어도 더 많이 조정해야 할 수 있다.
2026-03-01 ⋯ BERT 뉴스 분류 #2 Max length 최적화, 분류 헤드 확장
데이터 요약 데이터가 균등하다 (클래스별 같은 수). BERT 파인튜닝 BERT는 이미 위키피디아와 BookCorpus라는 방대한 텍스트로 사전 학습되어 있다. 수십억 개의 단어를 읽으면서 영어의 문법, 단어 간의 의미 관계, 문맥에 따른 의미 변화를 이미 체득한 상태다. 하지만 "이 뉴스가 스포츠인지 경제인지"를 판단하는 법은 배운 적이 없다.파인튜닝은 이 이미 똑똑한 모델을 우리 문제에 맞게 살짝 조정하는 것이다. 비유하자면 영어를 유창하게 하는 사람에게 뉴스 편집 업무를 가르치는 것과 같다. 영어 자체를 처음부터 가르칠 필요는 없다. "경제 기사는 보통 이런 단어들이 나오고, 스포츠 기사는 저런 단어들이 나온다"는 정도만 가르치면 된다. max_length 최적화 BERT는 최대 512개 토큰을 처리할 수 있다. 아무 생각 없이 max_length=512로 설정하면 작동은 한다. 하지만 이게 엄청난 낭비를 만든다. 실제 AG News 기사들의 토큰 길이를 분석해보면, 평균이 95개이고 95번째 퍼센타일이 180이다. 기사 대부분이 200 토큰도 안 된다는 뜻이다. 512로 설정하면 평균적으로 417개의 [PAD] 토큰이 뒤에 붙는다. 실제 내용이 95개이고 빈칸이 417개인 셈이다. "attention_mask가 패딩을 무시하게 해주지 않느냐"고 물을 수 있다. 맞다, attention_mask 덕분에 패딩 토큰의 가중치는 0이 된다. 하지만 Self-Attention의 행렬 계산 자체는 여전히 512×512 크기로 수행된다. attention_mask는 계산 결과에서 패딩의 영향력을 지우는 것이지, 계산 자체를 건너뛰는 게 아니다. 512×512 = 262,144번의 연산 중 실제로 의미 있는 건 95×95 = 9,025번뿐인데, 나머지 25만 번은 그냥 버려지는 것이다. max_length=192로 줄이면 192×192 = 36,864번의 연산만 하면 된다. 512 대비 7분의 1 수준이다. 95%의 기사가 완전히 보존되고, 나머지 5%만 끝이 약간 잘린다. 뉴스 분류에서 기사 앞부분만으로도 카테고리를 충분히 판단할 수 있으니, 이건 합리적인 트레이드오프다. 여기서 한 단계 더 최적화가 있다. Dynamic Padding이다. max_length=192로 고정 패딩을 하면, 40 토큰짜리 짧은 기사도 192로 늘어난다. 하지만 어떤 배치에 담긴 기사들이 모두 100 토큰 이하라면, 그 배치는 100까지만 패딩하면 충분하다. DataCollatorWithPadding이 바로 이 일을 한다. 배치를 구성할 때 그 배치 안에서 가장 긴 시퀀스의 길이에 맞춰 패딩한다. 배치마다 패딩 길이가 달라지므로 "동적(Dynamic)" 패딩이다. 고정 패딩에서 평균 120개의 빈칸이 붙었다면, 동적 패딩에서는 평균 83개 정도로 줄어든다. 작은 차이 같지만 12만 건을 처리하면 누적 효과가 크다. 이 최적화의 본질은 이전 챕터들에서 반복해서 본 원칙과 같다. DNA 분석에서 101bp 서열에는 큰 모델을, 21bp 서열에는 작은 모델을 썼다. 데이터의 실제 크기에 맞게 모델의 입력 크기를 조절하는 것이다. 필요 이상으로 큰 입력을 넣으면 계산 낭비와 과적합 위험만 늘어난다. 분류 헤드 구조 확장 기본 BERT의 분류 헤드는 극단적으로 단순하다. [CLS] 토큰에서 나온 768차원 벡터에 Dropout을 한 번 걸고, Linear(768 → 4)로 바로 4개 클래스 logit을 만든다. 한 방에 768차원에서 4차원으로 뛰어내리는 것이다. 이게 왜 문제가 될 수 있는가? 768차원 공간에서 4개 카테고리의 경계가 하나의 직선(정확히는 초평면)으로 깔끔하게 나뉘면 문제가 없다. 하지만 현실의 뉴스 기사는 그렇게 단순하지 않다. "Tech company wins lawsuit"은 기술 뉴스일까 경제 뉴스일까? "Olympic committee announces budget cuts"는 스포츠일까 경제일까? 카테고리 사이의 경계가 직선이 아니라 구불구불한 곡선일 수 있다. 단일 Linear 레이어는 직선 경계만 그을 수 있으므로, 이런 복잡한 경계를 표현하지 못한다. EnhancedClassifier는 중간 단계를 하나 추가한다. 768 → 256 → 4 구조다. 768차원 벡터가 먼저 256차원으로 압축되면서 비선형 활성화 함수(GELU)를 통과하고, 그 256차원 표현에서 4개 클래스로 분류한다. 이 중간 레이어가 존재하는 것만으로도 모델의 표현력이 크게 달라진다. 첫 번째 Linear가 768차원 공간을 256차원으로 사영(projection)하면서 정보를 재조합하고, GELU가 비선형성을 도입해서 구불구불한 경계를 그을 수 있게 만들어준다. 그 다음 두 번째 Linear가 이 재조합된 표현을 보고 최종 분류를 한다. 망막증 모델에서 GlobalMaxPooling 뒤에 바로 Dense(5)로 가지 않고 Dense(1024, relu) → Dense(5)로 중간 단계를 둔 것과 같은 설계 원리다. GELU라는 활성화 함수를 쓴 이유도 흥미롭다. 이전 챕터들에서는 ReLU를 썼다. ReLU는 입력이 양수면 그대로 통과시키고, 음수면 완전히 0으로 만든다. 이분법적이다. GELU는 다르다. 음수 입력도 확률적으로 일부 통과시킨다. 완전히 죽이지 않고 약간의 정보를 흘려보내는 것이다. BERT 본체가 내부적으로 GELU를 사용하므로, 분류 헤드도 GELU를 쓰면 모델 전체가 일관된 활성화 패턴을 유지한다. Dropout을 0.1에서 0.3으로 올린 것은 분류 헤드의 특수한 상황을 반영한다. BERT 본체는 1억 개가 넘는 파라미터가 이미 사전학습으로 잘 조정되어 있지만, 분류 헤드는 완전히 랜덤한 상태에서 시작한다. 사전학습의 보호막이 없는 셈이다. 이런 상황에서 과적합이 일어나기 쉬우므로 더 강한 정규화가 필요하다. DNA 분석에서 짧은 서열(21bp)에 0.3, 긴 서열(101bp)에 0.5를 쓴 것처럼, Dropout 비율은 과적합 위험도에 맞춰 조절하는 것이다.
2026-03-01 ⋯ BERT 뉴스 분류 #1 BERT와 Self-Attention
뉴스 기사 하나가 있다. "Wall St. Bears Claw Back Into the Black"이라는 제목을 보면 사람은 바로 안다. 이건 경제 기사다. 하지만 하루에 수백만 건의 뉴스가 쏟아지는 세상에서 사람이 하나하나 분류할 수는 없다. 컴퓨터가 기사 텍스트를 읽고 스포츠인지, 경제인지, 세계 뉴스인지, 기술 뉴스인지를 자동으로 판단하게 만들고 싶다. 또한 모델을 만들고 끝나는 게 아니라, 만든 모델을 실제로 서비스하고, 성능을 감시하고, 문제가 생기면 자동으로 재학습하는 전체 파이프라인을 구축한다. 연구실의 실험이 아니라 현실 세계의 운영 시스템을 만든다. | 파일 | 역할 | 단계 |
|---|---|---|
| `bert-01.ipynb` | BERT 파인튜닝 + ONNX 변환 | 모델 학습 |
| `bert-02-optuna.ipynb` | 하이퍼파라미터 탐색 + 다중 모델 비교 | 모델 고도화 |
| `bert-03-evaluation.ipynb` | 종합 평가 리포트 생성 | 모델 평가 |
| `main.py` | FastAPI 추론 서버 | 모델 서빙 |
| `retrain.py` | Champion-Challenger 자동 재학습 | 모델 갱신 |
| `monitor.py` | 드리프트 감지 + 슬랙 알림 | 모델 감시 | BERT가 텍스트를 이해하는 방법 DNA 분석에서 A, C, G, T를 원-핫 인코딩으로 바꿨듯이 텍스트도 마찬가지다. "Google announced a new product"라는 문장을 컴퓨터에게 보여줘도 컴퓨터는 그냥 바이트 덩어리로 볼 뿐, 의미를 모른다. 가장 기본적인 변환 방법은 단어 사전을 만드는 것이다. 모든 단어에 고유 번호를 매긴다. "apple"은 1042번, "google"은 8891번, "announced"는 3847번. 이 번호가 input_ids다. DNA의 원-핫 인코딩이 각 염기에 고유한 벡터를 부여했듯이, 여기서는 각 단어에 고유한 번호를 부여하는 것이다. 하지만 여기서 DNA와의 중요한 차이가 드러난다. DNA 알파벳은 4글자뿐이지만 영어 단어는 수만 개다. 그리고 DNA의 네 글자는 서로 완전히 독립적이지만, 영어 단어들은 의미적 관계가 있다. "good"과 "great"은 비슷한 뜻이고 "good"과 "terrible"은 반대다. 단순한 번호 매기기로는 이런 관계를 표현할 수 없다. 8891번과 3847번이라는 숫자 사이에는 아무런 의미적 관계가 없다. 이 문제를 해결하는 것이 BERT의 핵심이다. 하지만 BERT에 도달하기 전에 먼저 토크나이저라는 관문을 지나야 한다. 토크나이저: 문장을 숫자로 쪼개기 BERT의 토크나이저는 문장을 받아서 세 가지를 만들어낸다. 첫 번째는 input_ids다. 문장의 각 토큰(단어 또는 서브워드)에 해당하는 사전 번호 배열이다. 그런데 단순히 단어만 나열하는 게 아니라 특별한 토큰 두 개가 추가된다. 문장 맨 앞에 [CLS]라는 토큰이 붙고, 맨 뒤에 [SEP]라는 토큰이 붙는다. [CLS]는 "Classification"의 약자로, 나중에 이 위치의 출력 벡터가 문장 전체의 의미를 대표하게 된다. [SEP]는 문장의 끝을 표시한다. 두 번째는 attention_mask다. 이건 실제 단어가 있는 위치에 1, 패딩(빈칸)인 위치에 0이 들어간 배열이다. 왜 이게 필요한가? 신경망은 고정 길이의 입력을 받아야 하는데, 문장 길이는 제각각이다. 짧은 문장 뒤에 [PAD]라는 빈 토큰을 채워서 길이를 맞추는데, 이 빈 토큰은 아무 의미가 없다. attention_mask가 BERT에게 "여기는 진짜 단어니까 주목하고, 여기는 빈칸이니까 무시해"라고 알려주는 것이다. DNA 분석에서 padding='same'으로 서열 양 끝을 0으로 채웠던 것과 비슷한 개념이지만, 여기서는 명시적으로 마스크를 제공한다는 점이 다르다. 세 번째는 token_type_ids인데, BERT가 원래 두 문장을 입력받을 수 있도록 설계되어서 "첫 번째 문장에 속하는 토큰"과 "두 번째 문장에 속하는 토큰"을 구분하는 용도다. 우리는 단일 문장만 쓰니까 전부 0이다. truncation=True와 max_length=512는 BERT의 입력 길이 제한과 관련이 있다. BERT는 최대 512개 토큰만 처리할 수 있다. 그보다 긴 문장은 잘라내야 한다. 뉴스 기사의 제목과 앞부분만으로도 카테고리를 충분히 판단할 수 있으니, 뒷부분이 잘려도 큰 문제가 되지 않는다. BERT 내부 구조: Self-Attention 토큰 번호가 BERT에 들어가면 무슨 일이 벌어지는가? BERT의 핵심은 Transformer 인코더 12개를 쌓은 구조인데, 각 인코더 안에 Self-Attention이라는 메커니즘이 있다. Self-Attention이 왜 필요한지를 이해하려면 언어의 근본적인 특성을 생각해봐야 한다. 같은 단어도 맥락에 따라 완전히 다른 뜻을 가진다. "The bank by the river was flooded"에서 "bank"는 강둑이다. "I went to the bank to deposit money"에서 "bank"는 은행이다. 단어 자체만 봐서는 알 수 없고, 주변 단어들을 봐야 한다. "river"가 근처에 있으면 강둑이고, "money"가 근처에 있으면 은행이다. Self-Attention은 정확히 이 일을 한다. 문장 안의 각 단어가 다른 모든 단어를 "얼마나 참고해야 하는지"를 계산하는 것이다. "bank"라는 단어를 처리할 때, "river"에 높은 점수를 주고 "the"에 낮은 점수를 준다. 그 점수에 따라 다른 단어들의 정보를 가중합해서 "bank"의 표현을 업데이트한다. "river" 옆의 "bank"는 강둑의 의미를 흡수하고, "money" 옆의 "bank"는 은행의 의미를 흡수한다. 이 과정을 수학적으로 보면 Query, Key, Value라는 세 가지 벡터가 관여한다. 비유로 설명하면 이렇다. 도서관에서 책을 찾는 상황을 떠올려보자. Query는 "내가 찾고 있는 것"이다. "bank"라는 단어가 "나의 의미를 결정하려면 어떤 정보가 필요한가?"라고 묻는 것이다. Key는 "내가 제공할 수 있는 것"이다. 문장의 각 단어가 "나는 이런 정보를 가지고 있어"라고 자기를 소개하는 것이다. Query와 Key의 유사도를 계산하면 Attention Score가 나온다. "river"의 Key가 "bank"의 Query와 잘 맞으면 높은 점수가 나온다. Value는 "실제로 전달할 내용"이다. 높은 점수를 받은 단어의 Value가 더 많이 반영되어 최종 표현이 만들어진다. Attention(Q, K, V) = softmax(QK^T / √d_k) · V라는 공식에서 softmax(QK^T / √d_k) 부분이 "각 단어를 얼마나 볼지"를 결정하는 점수(가중치)이고, 이 가중치로 V를 가중합하는 것이다. √d_k로 나누는 건 점수가 너무 커지지 않게 하는 안정화 장치다. 이 Self-Attention을 12겹 쌓는다. 첫 번째 레이어에서는 직접적으로 이웃한 단어들의 관계를 잡고, 뒤쪽 레이어로 갈수록 문장 전체의 맥락을 통합한다. Conv1D를 여러 겹 쌓아서 점점 넓은 범위의 패턴을 잡았던 것과 유사한 원리다. 하지만 Conv1D는 고정된 크기의 창(kernel_size)만큼만 보는 반면, Self-Attention은 문장의 어떤 위치든 직접 참조할 수 있다. 문장의 첫 단어와 마지막 단어 사이의 관계도 단 한 번의 Attention으로 포착할 수 있다는 점이 Transformer의 결정적인 장점이다. 12개 레이어를 모두 통과하면, 맨 처음 넣어준 [CLS] 토큰의 위치에서 768차원의 벡터가 나온다. 이 벡터가 문장 전체의 의미를 압축한 것이다. 각 단어의 의미가 Self-Attention을 통해 서로에게 전달되면서, [CLS] 위치에 문장 전체의 정보가 모이게 된다. 이 768차원 벡터가 바로 "이 뉴스 기사가 무엇에 관한 것인가"를 담은 표현이다. 여기에 Dense 레이어를 붙여서 4개 카테고리로 분류하면 뉴스 분류기가 완성된다. 모델 서빙 이 분석은 모델 하나를 만드는 것에서 끝나지 않는다. 실제제 서비스 환경에서는 모델을 만든 후에 해야 할 일이 훨씬 많다. 모델을 ONNX 형식으로 변환해서 빠르게 추론할 수 있게 만들고, FastAPI 서버로 실시간 요청을 처리하고, 시간이 지나면서 뉴스의 경향이 바뀌면(새로운 주제가 등장하거나 단어 사용 패턴이 변하면) 모델 성능이 떨어지는 드리프트를 감지하고, 성능이 떨어지면 새 데이터로 자동 재학습하는 시스템을 구축한다. 이건 연구와 엔지니어링의 차이를 보여준다. 연구에서는 "이 모델의 정확도가 몇 퍼센트다"가 최종 결과물이다. 엔지니어링에서는 그 모델이 매일 수백만 건의 요청을 안정적으로 처리하면서, 세상이 변해도 성능을 유지하는 것이 최종 결과물이다.
2026-02-28 ⋯ VAE 신약 분자 생성 #4 전체 파이프라인
전체 파이프라인 정리 데이터셋은 MUV 데이터셋의 수만 개 분자다. 각 분자는 SMILES라는 문자열로 표현되어 있다. CCO, CC(=O)Oc1ccccc1C(=O)O 같은 문자열들이다. 이 문자열들이 train_dataset.ids에 담겨 있다. 이 문자열들을 모델에 넣으려면 먼저 "이 데이터에 어떤 글자들이 등장하는가"와 "가장 긴 문자열이 몇 글자인가"를 알아야 한다. 모든 SMILES를 훑어서 등장하는 고유 문자를 모으면 토큰 집합이 만들어진다. C, N, O, =, (, ) 같은 문자들이다. 이걸 정렬해서 순서를 고정하면 모델의 어휘가 완성된다. 가장 긴 SMILES의 길이가 120이라면, 모든 문자열을 120 길이로 패딩해서 입력 크기를 통일한다. 이 두 정보를 AspuruGuzikAutoEncoder에 넘기면 모델이 구성된다. 인코더 쪽에는 3층 GRU가 SMILES를 순서대로 읽어서 196차원의 μ와 σ를 내놓고, 재파라미터화 트릭으로 잠재 벡터 z를 샘플링한다. 디코더 쪽에는 3층 GRU가 z에서 SMILES를 한 글자씩 복원한다. 학습률은 0.001에서 시작해서 에포크마다 5%씩 줄어드는 ExponentialDecay를 쓴다. 학습은 generate_sequences라는 제너레이터가 (SMILES, SMILES) 쌍을 50 에포크 동안 공급하는 방식으로 진행된다. 같은 문자열을 입력으로도 정답으로도 주는 오토인코더 학습이다. 손실 함수는 재구성 손실(복원이 얼마나 정확한가)과 KL 발산(잠재 공간이 얼마나 규칙적인가)의 합이다. 이 두 힘이 균형을 이루면서, 모델은 분자를 정확히 복원하는 능력과 잠재 공간의 규칙성을 동시에 확보해나간다. 학습이 끝나면 생성 단계로 넘어간다. 표준 정규분포에서 196차원 벡터 1,000개를 랜덤으로 뽑아서 디코더에 넣으면, 1,000개의 SMILES 문자열이 한 글자씩 자기회귀적으로 생성된다. RDKit의 MolFromSmiles가 각 문자열의 화학적 유효성을 검증해서, 실제로 존재할 수 있는 분자만 걸러낸다. VAE가 일반 오토인코더보다 나은 이유 일반 오토인코더의 인코더는 각 분자를 잠재 공간의 하나의 고정된 점에 매핑한다. 학습이 잘 되면 그 점에서 디코딩하면 원래 분자가 나온다. 하지만 학습된 점들 사이의 빈 공간에서 디코딩하면 쓰레기가 나온다. 잠재 공간에 지도가 없는 셈이다. VAE는 두 가지를 바꾼다. 첫째, 인코더가 점이 아닌 분포를 내놓고 거기서 샘플링한다. 덕분에 디코더가 한 점이 아니라 그 주변 영역 전체에서 올바르게 작동하도록 학습된다. 둘째, KL 발산이 모든 분포를 N(0,1) 근처로 모은다. 덕분에 잠재 공간 전체가 균일하게 채워져서 빈 곳이 사라진다. 이 두 가지가 합쳐진 결과가 바로 잠재 공간의 연속성과 규칙성이다. 그리고 이 성질이 VAE를 단순한 압축기가 아닌 생성기로 만들어주는 핵심이다. | 항목 | 오토인코더 | 변분 오토인코더 |
|---|---|---|
| 잠재 벡터 | 결정론적 점 | 확률 분포 (μ, σ) |
| 잠재 공간 | 불규칙, 빈 곳 있음 | 균일하고 연속적 |
| 랜덤 샘플링 | 의미 없는 결과 가능 | 유효한 분자 생성 가능 |
| 학습 손실 | 재구성 손실만 | 재구성 손실 + KL 발산 |
| 분자 보간 | 불연속 점프 | 부드러운 전환 가능 | 잠재 공간 보간(interpolation) VAE의 잠재 공간이 연속적이라는 성질에서 아주 흥미로운 가능성이 열린다. 잠재 공간에서의 보간(interpolation)이다.
알려진 약물 분자 A의 SMILES를 인코더에 넣으면 잠재 벡터 zA가 나온다. 다른 약물 분자 B를 넣으면 zB가 나온다. 이제 zA와 zB의 정확히 중간 지점, 0.5 × zA + 0.5 × zB를 계산해서 디코더에 넣으면 어떻게 될까? 일반 오토인코더라면 이 중간 지점이 빈 공간일 수 있어서 쓰레기가 나올 가능성이 높다. 하지만 VAE에서는 잠재 공간이 연속적이므로, 이 중간 지점에서도 유효한 분자가 나올 가능성이 높다. 그리고 그 분자는 A와 B의 중간 성질을 가질 것이다. A가 항염증 효과가 강하고 B가 항균 효과가 강하다면, 중간 지점의 분자는 두 효과를 어느 정도 다 가진 분자일 수 있다. 이걸 더 발전시키면, zA에서 zB로 가는 경로를 잘게 나눠서 여러 중간 지점을 만들 수 있다. 각 지점을 디코딩하면 A에서 B로 서서히 변해가는 분자들의 연속체가 나온다. 이건 마치 색상 팔레트에서 빨강과 파랑 사이를 부드럽게 전환하면 보라색 계열이 나오는 것과 같다. 분자의 성질이 한쪽 극단에서 다른 극단으로 부드럽게 변하는 것이다. 이것이 VAE 기반 분자 설계의 핵심적인 응용이다. 이미 알려진 약물들 사이의 잠재 공간을 체계적으로 탐색하면서, 기존 약물의 좋은 성질을 조합한 새로운 후보 분자를 발견할 수 있다. 10의 60제곱이라는 방대한 화학 공간을 무작위로 탐색하는 대신, 196차원의 잘 정돈된 잠재 공간에서 의미 있는 방향으로 이동하며 탐색하는 것이다. 차원의 저주를 잠재 공간의 규칙성으로 극복하는 전략인 셈이다. 이것이 VAE 기반 분자 설계의 핵심 아이디어다:
알려진 두 약물 사이를 잠재 공간에서 탐색 → 새로운 후보 분자 발견 이전 DNA 분석과의 비교 데이터 표현 방식은 일관된다. DNA는 A, C, G, T 네 글자를 원-핫으로 바꿨고, SMILES는 수십 종의 토큰을 역시 숫자로 바꿨다. 어떤 종류의 데이터든 결국 숫자 배열로 변환해서 신경망에 넣는다는 원칙은 동일하다. 시퀀스 처리 도구는 문제에 맞게 달라진다. DNA의 모티프는 위치 독립적이고 국소적이어서 Conv1D가 적합했다. SMILES의 문법 구조는 순서 의존적이고 장거리 의존성이 있어서(괄호 열기와 닫기, 고리 번호 매칭) GRU가 적합했다. 망막 이미지는 2차원 공간 패턴이어서 Conv2D 기반의 ResNet이 적합했다. 도구는 다르지만 "입력 데이터의 구조적 특성에 맞는 신경망을 선택한다"는 설계 원칙은 항상 같다. 가장 큰 차이는 문제의 성격이다. 이전 분석들은 모두 판별 문제였다. 입력이 주어지면 레이블이나 수치를 예측했다. 이 분석 생성 문제다. 잠재 공간에서 새로운 것을 만들어낸다. 판별에서 생성으로의 전환은 딥러닝의 응용 범위가 "분류와 예측"을 넘어 "창조"의 영역으로 확장되는 것을 보여준다. 그리고 그 확장을 가능하게 만든 핵심 아이디어가 바로 잠재 공간의 규칙화, 즉 KL 발산이라는 단 하나의 항을 손실 함수에 추가한 것이라는 점이 놀랍다.
2026-02-28 ⋯ VAE 신약 분자 생성 #3 Seq2Seq 학습과 분자 생성
generator 함수 generate_sequences 함수는 이전 챕터에서 크로마틴 접근성 실험의 generate_batches와 같은 패턴이다. yield를 쓰는 제너레이터다.
바깥 루프가 50번 도니까 전체 학습 데이터를 50번 반복해서 보여주는 셈이다. 즉 50 에포크를 학습한다. 안쪽 루프는 모든 학습 SMILES를 하나씩 순회한다. yield (s, s)가 호출될 때마다 하나의 학습 샘플이 모델에 전달된다. 수만 개의 SMILES를 한꺼번에 메모리에 올려서 변환하는 대신, 하나씩 필요할 때마다 만들어서 넘기는 것이다. 실제로는 model.fit_sequences 내부에서 batch_size=100개씩 모아서 배치로 처리하겠지만, 제너레이터 덕분에 전체 데이터를 동시에 메모리에 올릴 필요가 없다. Seq2Seq 학습 학습 코드를 보면 yield (s, s)라고 되어 있다. 같은 SMILES 문자열을 입력으로도 주고 정답으로도 준다. 처음 보면 이상하다. 정답이 입력과 똑같으면 그냥 입력을 그대로 내보내면 되는 거 아닌가? 하지만 그게 안 되는 구조이기 때문에 의미가 있다. 중간에 병목(bottleneck)이 있다. SMILES 문자열이 인코더를 통과하면 196개의 숫자로 압축된다. 수십 글자의 정보가 196개의 실수로 쪼그라드는 것이다. 디코더는 이 196개의 숫자만 보고 원래 문자열을 복원해야 한다. 입력을 "그대로 내보내는" 지름길이 없다. 반드시 196차원의 좁은 통로를 거쳐야 한다. 이 통로가 좁기 때문에 모델은 분자의 본질적인 구조 정보만 이 196개의 숫자에 담는 법을 배우게 된다. 불필요한 세부 사항은 버리고 핵심만 남기는 것이다. 그리고 KL 발산이 이 196차원 공간을 표준 정규분포처럼 균일하게 정돈해서, 나중에 아무 데서나 점을 찍어도 의미 있는 분자가 나올 수 있게 만든다. Teacher Forcing 디코더가 SMILES를 생성할 때 한 글자씩 순서대로 만든다고 했다. 그런데 학습할 때와 실제 생성할 때의 방식이 다르다. 실제 생성할 때는 디코더가 방금 만든 글자를 다음 스텝의 입력으로 쓴다. "C"를 생성했으면 "C"를 다음 입력으로 넣어서 그 다음 글자를 예측한다.
학습할 때 같은 방식을 쓰면 문제가 생긴다. 초반에 모델이 잘 모르는 상태에서 첫 글자를 틀리면, 틀린 글자가 다음 스텝의 입력이 되고, 그 틀린 맥락에서 또 틀리고, 오류가 눈덩이처럼 불어난다. 첫 단추를 잘못 끼우면 나머지가 전부 엉망이 되는 것이다. 이러면 학습이 극도로 불안정해진다. Teacher Forcing은 이 문제를 해결한다. 학습할 때는 디코더가 생성한 글자 대신 정답 글자를 다음 스텝의 입력으로 넣어준다. 디코더가 두 번째 위치에서 "O"를 잘못 예측했더라도, 세 번째 스텝에는 정답인 "("를 입력으로 준다. "선생님"이 매 스텝마다 올바른 답을 알려주는 셈이다. 덕분에 하나의 실수가 연쇄적으로 퍼지지 않고, 각 스텝이 독립적으로 올바른 맥락에서 학습할 수 있다. 비유하자면 자전거를 배울 때 보조 바퀴를 달아주는 것과 같다. 넘어질 때마다 처음부터 다시 시작하면 학습이 너무 느리지만, 보조 바퀴가 잡아주면 페달 밟는 법, 핸들 잡는 법을 하나씩 안정적으로 익힐 수 있다. 충분히 익숙해지면(학습이 진행되면) 보조 바퀴 없이(실제 생성 방식으로) 잘 달릴 수 있게 된다. 새로운 분자 생성 학습이 끝나면 드디어 이 프로젝트의 진짜 목표인 새로운 분자 생성을 할 수 있다. np.random.normal(size=(1000, 196))이 하는 일은 표준 정규분포 N(0,1)에서 196차원 벡터 1,000개를 랜덤으로 뽑는 것이다. 각 벡터의 196개 원소가 각각 독립적으로 평균 0, 표준편차 1인 정규분포에서 샘플링된다. 왜 이것이 작동하는가? KL 발산 덕분이다. 학습 과정에서 KL 발산이 인코더의 출력 분포를 N(0,1)에 가깝게 밀어붙였다. 그 결과 잠재 공간 전체가 표준 정규분포처럼 균일하게 채워져 있다. 이 공간의 아무 점이나 찍으면 그 근처에 학습된 분자들의 정보가 있고, 디코더가 그 점을 유효한 분자로 변환할 수 있는 것이다. 자기회귀적 디코딩 (Autoregressive Decoding) 이 1,000개의 벡터가 predict_from_embeddings를 통해 디코더에 들어간다. 디코더는 각 벡터에 대해 자기회귀적 디코딩을 수행한다. 196차원 벡터가 Dense 레이어를 통과해서 GRU의 초기 은닉 상태가 된다. GRU가 첫 스텝에서 시작 토큰을 받아서 첫 문자의 확률 분포를 내놓는다. 거기서 하나를 골라서 다음 스텝의 입력으로 넣고, 또 다음 문자의 확률 분포를 내놓고, 이걸 종료 토큰이 나올 때까지 반복한다. 벡터 하나당 SMILES 문자열 하나가 생성되므로, 1,000개의 잠재 벡터에서 1,000개의 SMILES가 나온다. RDKit으로 SMILES 유효성 검증 생성된 1,000개의 SMILES가 모두 유효한 분자인 건 아니다. 디코더가 한 글자씩 독립적으로 확률에 따라 생성하다 보면, 괄호가 안 맞거나 원자가(원자가 만들 수 있는 결합 수)가 맞지 않는 문자열이 나올 수 있다. C(C(C 같은 건 괄호가 열리기만 하고 닫히지 않았으니 문법적으로 틀린 SMILES다. RDKit 라이브러리의 Chem.MolFromSmiles 함수가 이 검증을 해준다. 유효한 SMILES를 넣으면 분자 객체를 돌려주고, 무효한 SMILES를 넣으면 None을 돌려준다. 이 함수는 SMILES 문법뿐 아니라 화학적 타당성도 검사한다. 문법은 맞지만 탄소가 결합 다섯 개를 갖는 불가능한 구조 같은 것도 걸러낸다. 코드는 1,000개의 생성 결과를 하나씩 돌면서 MolFromSmiles가 None이 아닌 것, 즉 유효한 분자만 molecules 리스트에 담는다. 1,000개 중 600개가 유효하면 유효성 비율(validity rate)이 60%인 것이다. 이 유효성 비율이 모델 품질의 중요한 지표다. 잠재 공간이 잘 구성되어 있을수록, 즉 KL 발산과 재구성 손실의 균형이 잘 맞을수록, 랜덤 샘플링에서도 화학적으로 유효한 SMILES가 많이 나온다. 유효성이 낮다면 잠재 공간에 여전히 "디코더가 제대로 변환하지 못하는 빈 영역"이 많다는 뜻이고, 모델을 더 학습시키거나 구조를 바꿔야 한다는 신호다. 정리 수만 개의 약물 분자를 SMILES 문자열로 변환하고, VAE가 이 문자열들을 196차원의 매끄러운 잠재 공간으로 압축하는 법을 50 에포크에 걸쳐 학습한다. 학습이 끝나면 그 잠재 공간에서 아무 점이나 찍어서 디코더에 넣으면 새로운 SMILES가 한 글자씩 생성되고, RDKit이 화학적 유효성을 검증해서 진짜 쓸 수 있는 분자만 골라낸다. 이전 분석들이 "이 입력이 무엇인가?"를 맞추는 판별(discrimination) 문제였다면, 이 챕터는 "존재하지 않았던 것을 만들어내는" 생성(generation) 문제다. 같은 딥러닝 도구들(시퀀스 처리, 확률 분포, 손실 함수)이 완전히 다른 목적으로 재조합된 것이다.
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 서열을 보고 결합 여부를 예측하고, 망막 사진을 보고 등급을 예측했다. 이 챕터에서는 딥러닝으로 "생성"을 한다. 기존 데이터의 패턴을 학습해서 완전히 새로운 것을 만들어내는 것이다.