Conv1D 기반 DNA 분석 #2 전사인자 결합 부위 예측 #
#2026-02-27
#0 분석 목적
우리 몸의 세포 안에는 DNA라는 아주 긴 문자열이 있고 A, C, G, T 네 글자로 이루어져 있다. DNA는 유전자라는 “레시피"를 담고 있는데, 이 레시피가 항상 켜져 있는 건 아니며 특정 단백질이 DNA의 특정 위치에 물리적으로 달라붙어야 그 근처 유전자가 켜진다. 이렇게 달라붙어서 유전자를 켜고 끄는 단백질을 전사인자라고 부른다.
JUND가 전사인자 중 하나인데, 아무 데나 붙는 게 아니라 특정한 글자 패턴이 있는 곳에만 붙는다. 예를 들어 TGACTCA 같은 패턴을 좋아한다고 알려져 있다. 그런데 현실은 이렇게 깔끔하지 않고 정확히 그 패턴이 아니어도 비슷하면 붙기도 하고, 주변 서열의 맥락에 따라 붙고 안 붙고가 달라지기도 한다.
이에 분석 목적은 다음과 같다. DNA에서 101글자짜리 조각을 하나 잘라서 모델에게 보여주면 모델이 “여기엔 JUND가 붙겠다” 또는 “여기엔 안 붙겠다"라는 판단을 하게 만든다 즉 이진 분류 문제이다.
#
#1 데이터 구조
train_dataset/
├── metadata.csv.gzip ← 어떤 shard들이 있는지 목록
├── shard-0-X.joblib ← 입력 서열 (원-핫 인코딩된 DNA)
├── shard-0-y.joblib ← 정답 (0 또는 1)
├── shard-0-w.joblib ← 샘플 가중치
├── shard-0-ids.joblib ← 샘플 ID (예: chr22:20208963-20209064)
└── tasks.json ← [0] (태스크 인덱스)
데이터 파일 구조를 보면 shard라는 말이 나오는데 데이터가 너무 많아서 한 파일에 다 넣으면 메모리가 터지니까 여러 조각으로 나눠 저장한 것이다. 각 shard에는 X(입력 서열), y(정답 레이블 0 또는 1), w(샘플 가중치), ids(이 서열이 게놈의 어디에서 왔는지 주소)가 들어 있다. ID가 chr22:20208963-20209064 같은 형태인데, 이건 “22번 염색체의 20,208,963번째에서 20,209,064번째 위치까지"라는 위치 좌표이다.
#
#2 모델 구조 설명
features = tf.keras.Input(shape=(101, 4))
입력은 길이 101짜리 DNA 서열이며 원-핫 인코딩되어있다. shape (101, 4)이다.
for i in range(3):
prev = layers.Conv1D(filters=15, kernel_size=10, activation=tf.nn.relu, padding='same')(prev)
prev = layers.Dropout(rate=0.5)(prev)
모델의 핵심은 Conv1D, 1차원 합성곱이다.
사진을 인식하는 신경망에서는 Conv2D를 쓴다. 사진은 2차원(가로, 세로)이니까 작은 정사각형 필터가 이미지 위를 상하좌우로 움직이면서 패턴을 찾는데 고양이 귀의 삼각형 윤곽 같은 걸 찾는 식이다.
DNA 서열은 1차원이고 글자가 일렬로 쭉 늘어서 있다. 그래서 필터도 1차원으로 움직인다. 서열의 왼쪽 끝에서 시작해서 오른쪽으로 한 칸씩 미끄러지면서 “지금 내가 보고 있는 구간에 내가 찾는 패턴이 있나?” 하고 확인한다.
DNA 서열 (길이 101):
[A][C][G][T][A][G][C][T][G][A]...(101개)
kernel_size=10 필터가 10칸씩 훑는다:
┌──────────┐
[A][C][G][T][A][G][C][T][G][A] → 하나의 값 출력
┌──────────┐
[C][G][T][A][G][C][T][G][A][C] → 하나의 값 출력
┌──────────┐
...
kernel_size=10이라는 건 필터 하나가 한 번에 10개 염기를 동시에 본다는 뜻이다. 이 필터는 10×4 크기의 숫자 행렬인데(10개 위치 × 4개 가능한 염기), 처음에는 아무 의미 없는 랜덤 숫자로 채워져 있다. 이 필터가 서열 위를 미끄러진다. 1번째-10번째 염기를 보고 숫자 하나를 뱉고, 2번째-11번째를 보고 숫자 하나를 뱉고, 이런 식으로 쭉 간다. padding=‘same’이 있으니까 출력 길이가 입력 길이와 똑같이 101이 된다. 양쪽 끝에 0을 채워서 길이를 맞추는 것이다. 필터가 뱉는 숫자가 크면 “여기에 내가 찾는 패턴이 있다!“는 뜻이고, 작으면 “여기엔 없다"는 뜻이다. 학습이 진행되면서 이 필터의 숫자들이 조금씩 바뀐다. 처음에는 아무 의미 없는 패턴을 찾다가, 점점 JUND 결합에 실제로 중요한 서열 패턴을 인식하는 방향으로 진화한다. 마치 현미경의 초점을 맞추듯, 학습이 필터를 생물학적으로 의미 있는 모티프 탐지기로 다듬어가는 것이다.
filters=15라는 건 이런 필터를 15개 동시에 돌린다는 뜻이다. 왜 여러 개가 필요하냐? JUND가 결합하는지를 판단하려면 하나의 패턴만 봐서는 부족하기 때문이다. 어떤 필터는 TGACTCA 같은 핵심 결합 모티프를 잡고, 어떤 필터는 GC가 많이 반복되는 영역을 감지하고, 또 어떤 필터는 특정 “반(anti)-패턴"을 탐지할 수 있다. 15개의 필터가 각각 다른 관점에서 서열을 들여다보는 셈이다.
이 모델은 Conv1D + Dropout 조합을 3번 반복한다. 왜 한 번이면 안 되고 세 번이나 쌓을까? 첫 번째 레이어를 생각해보자. 첫 번째 레이어의 필터는 원본 DNA 서열을 직접 본다. 한 번에 10개 염기만 보니까, 인식할 수 있는 건 기껏해야 10글자 이내의 짧은 패턴이다. “여기 TGAC가 있네” 정도다. 두 번째 레이어는 첫 번째 레이어의 출력을 입력으로 받는다. 첫 번째 레이어가 “3번 위치에 TGAC 패턴 발견, 15번 위치에 GGC 패턴 발견” 같은 정보를 내놓으면, 두 번째 레이어는 이 정보들 사이의 관계를 본다. “TGAC 패턴 근처에 GGC 패턴이 있네?” 같은 더 복잡한 조합을 인식할 수 있다. 세 번째 레이어는 여기서 한 단계 더 나간다. “TGAC 근처에 GGC가 있고, 그 조합이 서열의 중앙부에 위치해 있다” 같은 훨씬 추상적이고 넓은 범위의 패턴을 잡아낼 수 있다.
즉 첫 번째 레이어는 글자를 인식하는 것이고, 두 번째 레이어는 단어를 인식하는 것이고, 세 번째 레이어는 문장의 의미를 파악하는 것이다. 각 단계가 이전 단계의 결과를 재료 삼아 점점 더 고차원적인 특징을 추출한다.
layers.Dropout(rate=0.5)
각 Conv1D 뒤에 Dropout(rate=0.5)가 붙어 있다. 이건 학습할 때 뉴런의 50%를 무작위로 꺼버리는 것이다. 이는 과적합을 막기위한것인데 매번 학습할 때마다 랜덤하게 뉴런 절반을 끄니까, 모델은 특정 뉴런 하나에 모든 것을 걸 수 없게 된다. “3번 필터가 꺼져도, 7번 필터가 꺼져도, 나머지 필터들만으로도 합리적인 판단을 내릴 수 있어야 해"라는 압력이 생긴다. 결과적으로 모델이 더 견고하고 일반화된 패턴을 학습하게 된다. 참고로 실제 예측(테스트)을 할 때는 Dropout이 꺼지고 모든 뉴런이 동원된다.
logits = layers.Dense(units=1)(layers.Flatten()(prev))
output = layers.Activation(tf.math.sigmoid)(logits)
keras_model = tf.keras.Model(inputs=features, outputs=[output, logits])
세 겹의 Conv1D를 통과하고 나면 (101, 15) 형태의 텐서가 나온다. 101개 위치 각각에 대해 15개 필터가 내놓은 값, 총 101×15 = 1,515개의 숫자가 있는 셈이다. 이 숫자들은 “서열의 어디에 어떤 패턴이 있는지"를 요약한 일종의 특징 지도다.
그런데 우리가 원하는 건 “결합한다/안 한다"라는 하나의 대답이다. 1,515개의 숫자를 어떻게 하나로 줄일까? 먼저 Flatten이 (101, 15)라는 2차원 구조를 (1515,)라는 1차원 벡터로 쭉 펼친다. 2차원 표를 한 줄로 늘어놓는 것이다. 구조 정보(어디가 몇 번째 위치였는지)가 사라지는 것 같지만, 사실 Dense 레이어의 가중치가 각 위치를 구분하는 법을 학습하기 때문에 정보 손실은 없다.
그 다음 Dense(units=1)이 1,515개의 숫자에 각각 가중치를 곱하고 다 더해서 숫자 하나를 만든다. 이 숫자를 logit이라고 부른다. logit은 양수일 수도 있고 음수일 수도 있고 크기에 제한이 없다. logit이 큰 양수면 “결합할 가능성이 높다”, 큰 음수면 “결합 안 할 가능성이 높다"라는 뜻이다. 마지막으로 Sigmoid 함수가 이 logit을 0과 1 사이의 숫자로 눌러준다. sigmoid는 S자 모양의 곡선인데, 큰 양수를 넣으면 1에 가까운 값이 나오고, 큰 음수를 넣으면 0에 가까운 값이 나오고, 0을 넣으면 정확히 0.5가 나온다. 이렇게 변환된 값을 확률로 해석할 수 있다. “이 서열에 JUND가 결합할 확률이 0.87이다” 같은 식으로.
model = dc.models.KerasModel(
keras_model,
loss=dc.models.losses.SigmoidCrossEntropy(),
output_types=['prediction', 'loss'],
batch_size=1000,
model_dir='tf')
모델이 output(sigmoid 적용한 확률)과 logits(sigmoid 적용 전 raw 값) 두 개를 동시에 내보내는데 수학적으로는 같은 정보지만 용도가 다르다. 평가할 때는 output을 쓴다. “이 서열의 결합 확률이 0.87이다"라고 보고하려면 0~1 사이 확률이 필요하니까. 손실(loss) 계산할 때는 logit을 쓰는데 이유는 수치 안정성 때문이다. sigmoid 함수를 먼저 적용한 다음에 log를 취하면, 확률이 0이나 1에 아주 가까울 때 log(0)에 근접해서 숫자가 폭발하거나(무한대가 되거나) 정밀도가 뭉개질 수 있다. 그래서 SigmoidCrossEntropy 손실 함수는 내부적으로 logit을 직접 받아서 sigmoid와 log를 한 번에 계산하는 수학적 트릭을 쓴다. 이렇게 하면 극단적인 값에서도 안정적으로 계산할 수 있다. 컴퓨터가 소수점 계산에서 오류를 일으키는 걸 방지하기 위한 엔지니어링 방법이다.
손실 함수는 모델이 얼마나 틀렸는지 재는 도구이다. SigmoidCrossEntropy, 이진 교차 엔트로피의 공식은 이렇다.
Loss = -[y·log(p) + (1-y)·log(1-p)]
SigmoidCrossEntropy (이진 교차 엔트로피):
- y=1(결합): p가 클수록 손실 감소
- y=0(비결합): p가 작을수록 손실 감소
y는 정답(0 또는 1), p는 모델이 예측한 확률이다. 만약 정답이 “결합한다”(y=1)인데 모델이 p=0.9라고 높은 확률을 예측했다면, loss = -log(0.9) ≈ 0.1이다. 거의 맞췄으니 벌점이 작다. 반대로 p=0.1이라고 낮게 예측했다면, loss = -log(0.1) ≈ 2.3이다. 크게 틀렸으니 벌점이 크다. 정답이 “안 붙는다”(y=0)일 때도 마찬가지다. 모델이 p=0.1로 예측하면 loss = -log(0.9) ≈ 0.1로 벌점이 작고, p=0.9로 잘못 예측하면 loss = -log(0.1) ≈ 2.3으로 벌점이 크다.
핵심은 모델이 자신감 있게 틀리면 벌점이 기하급수적으로 커진다. “확실히 결합한다고 봅니다!“라고 말했는데 실제로는 안 붙는 경우, 모델은 엄청난 페널티를 받는다. 이 압력 덕분에 모델은 점점 더 정확한 예측을 하는 방향으로 학습된다.
metric = dc.metrics.Metric(dc.metrics.roc_auc_score)
for i in range(20):
model.fit(train, nb_epoch=10)
print(model.evaluate(train, [metric]))
print(model.evaluate(valid, [metric]))
모델이 잘 하고 있는지를 재는 척도로 ROC-AUC를 쓴다.
테스트 데이터에서 실제로 JUND가 결합하는 서열(양성) 하나와, 실제로 결합하지 않는 서열(음성) 하나를 무작위로 뽑는다. 모델한테 둘 다 보여주고 예측 확률을 얻는다. 만약 양성 서열에 대한 예측 확률이 음성 서열보다 높다면, 모델이 이 한 쌍에 대해서는 옳은 판단을 한 거다. 이 실험을 가능한 모든 양성-음성 쌍에 대해 반복한다. “양성의 예측값이 음성보다 높은 비율"이 바로 ROC-AUC다. ROC-AUC가 0.5면 동전 던지기 수준이다. 양성이든 음성이든 예측값이 반반으로 섞여 있어서 구분이 안 된다는 뜻이다. ROC-AUC가 1.0이면 완벽하다. 모든 양성 서열이 모든 음성 서열보다 높은 점수를 받았다는 뜻이다. ROC-AUC의 장점은 임계값(threshold) 선택에 영향을 받지 않는다는 것이다. “확률 0.5 이상이면 결합이라고 하자"라는 기준을 정하기 전에, 모델이 양성과 음성을 전반적으로 잘 구분하고 있는지를 보여주는 포괄적인 지표이다.
학습 루프를 보면 바깥 루프가 20번, 안쪽이 10 에포크씩 돌아서 총 200 에포크를 학습한다. 에포크 하나는 전체 훈련 데이터를 한 바퀴 도는 것이다. 매 10 에포크마다 훈련 세트와 검증 세트의 ROC-AUC를 출력한다. 이걸 왜 두 개 다 보느냐? 훈련 성능만 좋아지고 검증 성능이 정체되거나 떨어지면 과적합이 시작된 거다. 두 값을 비교하면서 “이 모델이 진짜로 패턴을 배운 건지, 아니면 답안지를 외운 건지"를 감시할 수 있다. batch_size=1000은 한 번에 1,000개의 서열을 묶어서 처리한다는 뜻이다. 하나씩 보면 너무 느리고 그래디언트(학습 방향)가 불안정하다. 전체를 한 번에 보면 메모리가 모자란다. 1,000개는 그 사이의 절충이다.
#
#4 정리
101글자짜리 DNA 조각을 원-핫 인코딩으로 숫자 행렬로 바꾸고, 세 겹의 1차원 합성곱 필터가 서열을 훑으면서 점점 더 복잡한 패턴을 추출하고, 그 패턴들을 하나의 숫자로 압축해서 sigmoid로 확률을 만들고, 이진 교차 엔트로피로 틀린 정도를 재서 필터의 숫자들을 조금씩 고쳐나가는 과정을 200번 반복하면, 결국 JUND가 어디에 붙을지 꽤 잘 맞추는 모델이 만들어진다.
결국 이 모델이 하는 일은, 생물학자가 실험으로 하나하나 확인해야 했던 “이 DNA 조각에 JUND가 붙을까?“라는 질문에 대해, 서열의 패턴만 보고 확률적으로 답하는 것이다. 수십 개의 학습된 필터가 각각 다른 서열 특징을 감지하고, 그 정보를 종합해서 하나의 예/아니오 판단을 내리는 구조다.