VAE 신약 분자 생성 #3 Seq2Seq 학습과 분자 생성 #
#2026-02-28
#1 generator 함수
def generate_sequences(epochs):
for i in range(epochs):
for s in train_smiles:
yield (s, s)
model.fit_sequences(generate_sequences(50))
generate_sequences 함수는 이전 챕터에서 크로마틴 접근성 실험의 generate_batches와 같은 패턴이다. yield를 쓰는 제너레이터다. 바깥 루프가 50번 도니까 전체 학습 데이터를 50번 반복해서 보여주는 셈이다. 즉 50 에포크를 학습한다. 안쪽 루프는 모든 학습 SMILES를 하나씩 순회한다. yield (s, s)가 호출될 때마다 하나의 학습 샘플이 모델에 전달된다.
수만 개의 SMILES를 한꺼번에 메모리에 올려서 변환하는 대신, 하나씩 필요할 때마다 만들어서 넘기는 것이다. 실제로는 model.fit_sequences 내부에서 batch_size=100개씩 모아서 배치로 처리하겠지만, 제너레이터 덕분에 전체 데이터를 동시에 메모리에 올릴 필요가 없다.
yield를 쓰는 이유:
- 수만 개의 SMILES를 한꺼번에 메모리에 올리지 않아도 됨
- 에포크를 반복할 때 같은 데이터를 다시 순회 (50 에포크)
#
#2 Seq2Seq 학습
yield (s, s) # (입력 시퀀스, 타겟 시퀀스)
학습 코드를 보면 yield (s, s)라고 되어 있다. 같은 SMILES 문자열을 입력으로도 주고 정답으로도 준다. 처음 보면 이상하다. 정답이 입력과 똑같으면 그냥 입력을 그대로 내보내면 되는 거 아닌가?
하지만 그게 안 되는 구조이기 때문에 의미가 있다. 중간에 병목(bottleneck)이 있다. SMILES 문자열이 인코더를 통과하면 196개의 숫자로 압축된다. 수십 글자의 정보가 196개의 실수로 쪼그라드는 것이다. 디코더는 이 196개의 숫자만 보고 원래 문자열을 복원해야 한다. 입력을 “그대로 내보내는” 지름길이 없다. 반드시 196차원의 좁은 통로를 거쳐야 한다.
이 통로가 좁기 때문에 모델은 분자의 본질적인 구조 정보만 이 196개의 숫자에 담는 법을 배우게 된다. 불필요한 세부 사항은 버리고 핵심만 남기는 것이다. 그리고 KL 발산이 이 196차원 공간을 표준 정규분포처럼 균일하게 정돈해서, 나중에 아무 데서나 점을 찍어도 의미 있는 분자가 나올 수 있게 만든다.
입력: 'CC(=O)Oc1ccccc1C(=O)O'
출력: 'CC(=O)Oc1ccccc1C(=O)O' ← 동일
# 오토인코더: 입력을 압축(인코딩)했다가 다시 복원(디코딩)하는 것이 목표.
#
#3 Teacher Forcing
디코더가 SMILES를 생성할 때 한 글자씩 순서대로 만든다고 했다. 그런데 학습할 때와 실제 생성할 때의 방식이 다르다. 실제 생성할 때는 디코더가 방금 만든 글자를 다음 스텝의 입력으로 쓴다. “C"를 생성했으면 “C"를 다음 입력으로 넣어서 그 다음 글자를 예측한다. 학습할 때 같은 방식을 쓰면 문제가 생긴다. 초반에 모델이 잘 모르는 상태에서 첫 글자를 틀리면, 틀린 글자가 다음 스텝의 입력이 되고, 그 틀린 맥락에서 또 틀리고, 오류가 눈덩이처럼 불어난다. 첫 단추를 잘못 끼우면 나머지가 전부 엉망이 되는 것이다. 이러면 학습이 극도로 불안정해진다.
Teacher Forcing은 이 문제를 해결한다. 학습할 때는 디코더가 생성한 글자 대신 정답 글자를 다음 스텝의 입력으로 넣어준다. 디코더가 두 번째 위치에서 “O"를 잘못 예측했더라도, 세 번째 스텝에는 정답인 “(“를 입력으로 준다. “선생님"이 매 스텝마다 올바른 답을 알려주는 셈이다. 덕분에 하나의 실수가 연쇄적으로 퍼지지 않고, 각 스텝이 독립적으로 올바른 맥락에서 학습할 수 있다.
비유하자면 자전거를 배울 때 보조 바퀴를 달아주는 것과 같다. 넘어질 때마다 처음부터 다시 시작하면 학습이 너무 느리지만, 보조 바퀴가 잡아주면 페달 밟는 법, 핸들 잡는 법을 하나씩 안정적으로 익힐 수 있다. 충분히 익숙해지면(학습이 진행되면) 보조 바퀴 없이(실제 생성 방식으로) 잘 달릴 수 있게 된다.
디코더의 학습 방식 — Teacher Forcing:
타겟: [C][C][(][=][O][)]...
↑ ↑ ↑
학습 시: 이전 스텝의 정답 문자를 디코더 입력으로 사용
(추론 시와 달리 실제 정답을 미리 보여줌)
→ 학습 안정화 및 가속
#
#5 새로운 분자 생성
학습이 끝나면 드디어 이 프로젝트의 진짜 목표인 새로운 분자 생성을 할 수 있다.
predictions = model.predict_from_embeddings(np.random.normal(size=(1000, 196)))
np.random.normal(size=(1000, 196))이 하는 일은 표준 정규분포 N(0,1)에서 196차원 벡터 1,000개를 랜덤으로 뽑는 것이다. 각 벡터의 196개 원소가 각각 독립적으로 평균 0, 표준편차 1인 정규분포에서 샘플링된다.
np.random.normal(size=(1000, 196)):
샘플 1: [-0.23, 1.45, 0.08, -1.12, ..., 0.67] (196차원)
샘플 2: [0.91, -0.34, 1.23, 0.05, ..., -0.88]
...
샘플 1000: [...]
각 벡터 → 디코더 → 문자 시퀀스 생성
왜 이것이 작동하는가? KL 발산 덕분이다. 학습 과정에서 KL 발산이 인코더의 출력 분포를 N(0,1)에 가깝게 밀어붙였다. 그 결과 잠재 공간 전체가 표준 정규분포처럼 균일하게 채워져 있다. 이 공간의 아무 점이나 찍으면 그 근처에 학습된 분자들의 정보가 있고, 디코더가 그 점을 유효한 분자로 변환할 수 있는 것이다.
#
#6 자기회귀적 디코딩 (Autoregressive Decoding)
이 1,000개의 벡터가 predict_from_embeddings를 통해 디코더에 들어간다. 디코더는 각 벡터에 대해 자기회귀적 디코딩을 수행한다.
z (196차원 벡터)
↓ Dense → GRU 초기 은닉 상태
스텝 1: 시작 토큰 → GRU → Softmax(['C':0.45, 'O':0.12, 'N':0.08, ...]) → 'C' 샘플링
스텝 2: 'C' + h₁ → GRU → Softmax → 'C' 샘플링
스텝 3: 'C' + h₂ → GRU → Softmax → '(' 샘플링
스텝 4: '(' + h₃ → GRU → Softmax → '=' 샘플링
...
종료 토큰이 나올 때까지 반복
196차원 벡터가 Dense 레이어를 통과해서 GRU의 초기 은닉 상태가 된다. GRU가 첫 스텝에서 시작 토큰을 받아서 첫 문자의 확률 분포를 내놓는다. 거기서 하나를 골라서 다음 스텝의 입력으로 넣고, 또 다음 문자의 확률 분포를 내놓고, 이걸 종료 토큰이 나올 때까지 반복한다. 벡터 하나당 SMILES 문자열 하나가 생성되므로, 1,000개의 잠재 벡터에서 1,000개의 SMILES가 나온다.
molecules = []
for p in predictions:
smiles = ''.join(p)
if Chem.MolFromSmiles(smiles) is not None:
molecules.append(smiles)
#
#7 RDKit으로 SMILES 유효성 검증
생성된 1,000개의 SMILES가 모두 유효한 분자인 건 아니다. 디코더가 한 글자씩 독립적으로 확률에 따라 생성하다 보면, 괄호가 안 맞거나 원자가(원자가 만들 수 있는 결합 수)가 맞지 않는 문자열이 나올 수 있다. C(C(C 같은 건 괄호가 열리기만 하고 닫히지 않았으니 문법적으로 틀린 SMILES다.
from rdkit import Chem
Chem.MolFromSmiles('CCO') # → Mol 객체 (유효)
Chem.MolFromSmiles('C(C(C') # → None (괄호 불일치, 무효)
Chem.MolFromSmiles('c1ccccc1') # → Mol 객체 (벤젠, 유효)
RDKit 라이브러리의 Chem.MolFromSmiles 함수가 이 검증을 해준다. 유효한 SMILES를 넣으면 분자 객체를 돌려주고, 무효한 SMILES를 넣으면 None을 돌려준다. 이 함수는 SMILES 문법뿐 아니라 화학적 타당성도 검사한다. 문법은 맞지만 탄소가 결합 다섯 개를 갖는 불가능한 구조 같은 것도 걸러낸다.
if Chem.MolFromSmiles(smiles) is not None:
molecules.append(smiles) # 유효한 분자만 수집
코드는 1,000개의 생성 결과를 하나씩 돌면서 MolFromSmiles가 None이 아닌 것, 즉 유효한 분자만 molecules 리스트에 담는다. 1,000개 중 600개가 유효하면 유효성 비율(validity rate)이 60%인 것이다.
# **유효성 비율(validity rate)** 이 모델 품질의 중요한 지표다:
1000개 샘플링 → 600개 유효 → validity = 60%
이 유효성 비율이 모델 품질의 중요한 지표다. 잠재 공간이 잘 구성되어 있을수록, 즉 KL 발산과 재구성 손실의 균형이 잘 맞을수록, 랜덤 샘플링에서도 화학적으로 유효한 SMILES가 많이 나온다. 유효성이 낮다면 잠재 공간에 여전히 “디코더가 제대로 변환하지 못하는 빈 영역"이 많다는 뜻이고, 모델을 더 학습시키거나 구조를 바꿔야 한다는 신호다.
#
#8 정리
수만 개의 약물 분자를 SMILES 문자열로 변환하고, VAE가 이 문자열들을 196차원의 매끄러운 잠재 공간으로 압축하는 법을 50 에포크에 걸쳐 학습한다. 학습이 끝나면 그 잠재 공간에서 아무 점이나 찍어서 디코더에 넣으면 새로운 SMILES가 한 글자씩 생성되고, RDKit이 화학적 유효성을 검증해서 진짜 쓸 수 있는 분자만 골라낸다.
이전 분석들이 “이 입력이 무엇인가?“를 맞추는 판별(discrimination) 문제였다면, 이 챕터는 “존재하지 않았던 것을 만들어내는” 생성(generation) 문제다. 같은 딥러닝 도구들(시퀀스 처리, 확률 분포, 손실 함수)이 완전히 다른 목적으로 재조합된 것이다.