생명과학 AI의 평가와 누수

먼저 읽을 장 29.1 ML 연구자를 위한 분자생물학 기초15.4 데이터 분할과 누수 방지

생명과학 데이터의 샘플은 독립이 아니다. 단백질과 유전자는 진화로 이어진 가족을 이루고, 같은 가족의 서열은 비슷한 구조와 기능을 가진다. 그래서 무작위로 나눈 test set의 거의 모든 샘플에는 train에 가까운 친척이 있고, 모델은 친척의 답을 베끼는 것만으로 높은 점수를 받는다. 이 점수는 "처음 보는 단백질 가족에서도 통한다"는 주장을 뒷받침하지 못한다. 15.4장의 일반 원리, 즉 분할을 주장에 맞추고 test 정보의 유입 경로를 막는다는 원리가 생명과학에서는 진화적 유사성이라는 특별한 형태로 나타난다.

평가는 계산으로 끝나지도 않는다. 생명과학 AI의 많은 결과는 "이 서열이 활성이 있을 것이다", "이 구조가 맞을 것이다" 같은 예측이고, 그 예측은 결국 실험으로 확인된다. 이 장은 유사성 기반 분할, 벤치마크에 숨은 누수, 계산 결과에서 실험 검증으로 이어지는 평가 설계, 그리고 그 전체를 재현할 수 있게 하는 기록을 다룬다.

학습 목표

  • 진화적 유사성에 의한 누수를 방지한다
  • 계산 결과와 실험 검증의 관계를 이해한다
  • 평가를 재현할 수 있도록 데이터·소프트웨어·무작위성을 기록한다

유사성 기반 누수

왜 필요한가. 무작위 분할은 1-최근접 이웃을 최고의 모델로 만들고, 가족 단위로 나누면 순위가 뒤집힌다(29.2장의 실험). 실제 데이터에는 가족 번호가 주어지지 않는다. 서열 사이의 유사도로 가족을 직접 정의해야 한다.

서열 동일성. 두 서열을 정렬(alignment)했을 때 같은 잔기가 놓인 위치의 비율을 서열 동일성(sequence identity)이라 한다. 실제 서열은 삽입·삭제가 있어 정렬이 먼저 필요하고, 동일성을 정렬 길이로 나누느냐 짧은 서열 길이로 나누느냐에 따라 값이 달라진다. 이 장의 합성 서열은 치환만 있어 위치끼리 바로 비교한다.

군집 분할. 동일성이 기준값 tt 이상인 서열끼리 같은 군집에 넣고, 군집을 통째로 train 또는 test에 배정한다. 15.4장의 group split에서 그룹을 서열 유사도로 정의한 것이다. 군집을 만드는 방식에 따라 보장되는 성질이 다르다.

  • 단일 연결(single linkage): 동일성 ≥t\ge t인 쌍을 간선으로 잇는 그래프의 연결 성분을 군집으로 삼는다. 서로 다른 군집의 어떤 두 서열도 동일성이 tt 미만이다. 따라서 군집 분할 후 test의 모든 서열은 train의 모든 서열과 동일성 tt 미만이다. 대가로, 서열이 많으면 사슬처럼 이어져 거대한 군집 하나가 생길 수 있다.
  • 대표 서열 기준의 탐욕적 군집: 서열을 하나씩 보며 기존 대표 서열과 동일성이 tt 이상이면 그 군집에, 아니면 새 대표로 삼는다. 대규모 데이터베이스에서 빠르게 중복을 줄이는 데 쓰인다. 각 서열은 자기 대표와만 tt 이상이 보장되고, 다른 군집의 서열과 tt 이상일 수 있다. 분할에 쓰면 test 서열과 train 서열 사이에 tt 이상의 쌍이 남을 수 있다.

대규모 서열 검색과 군집에는 MMseqs2 같은 도구가 쓰인다(Steinegger·Söding, 2017; 도구와 옵션은 집필 시점 예시). 어떤 도구든 동일성의 정의, 기준값, 군집 방식을 기록한다.

합성 데이터로 확인한다. 상위 가족 15개, 상위 가족마다 가족 4개, 가족마다 서열 5개(총 300개)를 진화 과정처럼 만든다. 단일 연결 군집(기준 0.3)으로 나눈 분할과 무작위 분할에서, test 서열마다 train과의 최대 동일성(가장 가까운 친척과의 동일성)을 잰다.

import numpy as np
from scipy.sparse.csgraph import connected_components

rng = np.random.default_rng(0)
L = 80

def evolve(seq, rate):                                 # 각 자리를 rate 확률로 무작위 잔기로 바꾼다
    out = seq.copy()
    hit = rng.random(len(seq)) < rate
    out[hit] = rng.integers(0, 20, hit.sum())
    return out

seqs, sup = [], []
for s in range(15):                                    # 상위 가족 15개
    root = rng.integers(0, 20, L)
    for f in range(4):                                 # 가족 4개씩
        anc = evolve(root, 0.35)
        for _ in range(5):                             # 구성원 5개씩
            seqs.append(evolve(anc, 0.15)); sup.append(s)
S, sup = np.array(seqs), np.array(sup)                 # (300, 80), (300,)
ident = (S[:, None, :] == S[None, :, :]).mean(-1)      # (300, 300) 정렬 없는 서열 동일성

def identity_clusters(ident, t):                       # 동일성 ≥ t로 이어진 서열은 같은 군집(단일 연결)
    return connected_components(ident >= t, directed=False)[1]

def max_id_to_train(test, train):                      # test 서열마다 train에서 가장 가까운 친척
    return ident[np.ix_(test, train)].max(1)

clusters = identity_clusters(ident, 0.3)
random_test = rng.permutation(300) < 60                # 무작위 20%
cl_ids = rng.permutation(clusters.max() + 1)[:int(0.2 * (clusters.max() + 1))]
cluster_test = np.isin(clusters, cl_ids)               # 군집 단위 약 20%
print("동일성 0.3 기준 군집 수:", clusters.max() + 1)
for name, te in [("무작위 분할", random_test), ("군집 분할", cluster_test)]:
    m = max_id_to_train(np.where(te)[0], np.where(~te)[0])
    print(f"{name}: test {te.sum()}개, train과의 최대 동일성 중앙값 {np.median(m):.2f}, 최댓값 {m.max():.2f}")
동일성 0.3 기준 군집 수: 15
무작위 분할: test 60개, train과의 최대 동일성 중앙값 0.78, 최댓값 0.86
군집 분할: test 60개, train과의 최대 동일성 중앙값 0.11, 최댓값 0.16

동일성 0.3 기준의 군집은 15개로, 이 데이터에서는 상위 가족과 일치한다. 무작위 분할에서는 test 서열의 절반 이상이 train에 동일성 0.78 이상의 친척을 가진다. 군집 분할에서는 가장 가까운 친척도 0.16을 넘지 않는다. 단일 연결의 보장대로 모든 test 서열이 기준 0.3 미만이다.

성능은 train과의 거리의 함수다. 가장 비슷한 train 서열의 label을 옮기는 모델(서열 유사성으로 기능을 추정하는 전통적인 방식)을 세 가지 분할로 평가한다. 무작위 분할, 가족 단위 분할, 상위 가족(군집) 단위 분할이다. label은 상위 가족마다 정하고, 가족의 15%는 기능이 갈라진 것으로 두었다. 분할을 5번 반복한 test 결과를 모아 train과의 최대 동일성 구간별 정확도도 본다.

fam = np.repeat(np.arange(60), 5)                      # (300,) 가족 번호
sup_label = rng.integers(0, 2, 15)[np.arange(60) // 4] # (60,) 가족이 속한 상위 가족의 기능
fam_label = np.where(rng.random(60) < 0.15, 1 - sup_label, sup_label)   # 가족의 15%는 기능이 갈라졌다
y = fam_label[fam]                                     # (300,)

def nn_transfer(test, train):                          # 가장 비슷한 train 서열의 label을 옮긴다
    nearest = train[ident[np.ix_(test, train)].argmax(1)]
    return y[nearest] == y[test], max_id_to_train(test, train)

n_cl = clusters.max() + 1
records = {"무작위 분할": [], "가족 분할": [], "군집 분할": []}
for seed in range(5):                                  # 분할을 5번 반복해 test 결과를 모은다
    r = np.random.default_rng(seed)
    splits = {"무작위 분할": r.permutation(300) < 60,
              "가족 분할": np.isin(fam, r.permutation(60)[:12]),
              "군집 분할": np.isin(clusters, r.permutation(n_cl)[:max(1, n_cl // 5)])}
    for name, te in splits.items():
        records[name].append(np.stack(nn_transfer(np.where(te)[0], np.where(~te)[0]), 1))
for name in records:
    records[name] = np.vstack(records[name])           # (n, 2) 정답 여부, train과의 최대 동일성
    print(f"{name}: 정확도 {records[name][:, 0].mean():.2f}")
pooled = np.vstack(list(records.values()))
for lo, hi in [(0.0, 0.3), (0.3, 0.6), (0.6, 1.0)]:
    sel = (pooled[:, 1] >= lo) & (pooled[:, 1] < hi)
    print(f"  최대 동일성 [{lo:.1f}, {hi:.1f}): {sel.sum():4d}개, 정확도 {pooled[sel, 0].mean():.2f}")
무작위 분할: 정확도 1.00
가족 분할: 정확도 0.73
군집 분할: 정확도 0.49
  최대 동일성 [0.0, 0.3):  312개, 정확도 0.51
  최대 동일성 [0.3, 0.6):  288개, 정확도 0.72
  최대 동일성 [0.6, 1.0):  300개, 정확도 1.00

같은 모델이 분할에 따라 1.00, 0.73, 0.49를 받는다. 구간별로 보면 이유가 분명하다. 가까운 친척(동일성 0.6 이상)이 있으면 정확도 1.00, 먼 친척(0.3–0.6)만 있으면 0.72, 친척이 없으면(0.3 미만) 우연 수준이다. 이 모델은 "기능을 예측"하는 것이 아니라 "친척을 찾아 베끼는" 모델이고, 성능은 test 서열이 train에서 얼마나 가까운지에 따라 정해진다. 그러므로 하나의 숫자 대신 train과의 최대 동일성 구간별 성능을 보고해야, 독자가 자기 서열(새 가족인가, 잘 알려진 가족인가)에 맞는 성능을 읽을 수 있다.

구조 유사성 기반 분할. 서열 동일성이 거의 남지 않은 먼 친척도 구조는 닮은 경우가 많다(29.3장). 서열 기준으로 나눈 test에도 구조가 같은 단백질이 train에 있을 수 있다. 구조나 기능 예측에서 "새로운 접힘 구조(fold)로의 일반화"를 주장하려면 TM-score 같은 구조 유사도나 구조 분류 체계의 상위 단위로 나눈다. TAPE의 원격 상동성 과제가 이런 분할의 예다(Rao 외, 2019).

벤치마크의 함정

왜 필요한가. 공개 벤치마크의 분할을 그대로 쓰면 비교는 쉽지만, 그 분할이 내 주장에 맞는지는 따로 확인해야 한다. 벤치마크에는 만든 사람이 의도하지 않은 누수가 숨어 있을 수 있다.

시간 분할. 데이터베이스 등록 날짜로 나누면 "미래의 새 서열·구조"를 흉내 낸다. 그러나 새로 등록된 단백질의 상당수는 이미 등록된 단백질의 친척이다. 시간 분할은 미래 시점에 대한 일반화를 재지만, 새 가족에 대한 일반화를 보장하지 않는다. 두 주장을 모두 하려면 시간 분할 위에 동일성 기준을 함께 건다(미래 데이터 중 과거 데이터와 동일성 tt 미만인 것만 test로).

데이터베이스 중복. 같은 단백질이 다른 식별자로 여러 번 등록되거나, 같은 서열이 여러 데이터셋에 겹쳐 들어 있다. 여러 출처의 데이터를 합친 벤치마크는 출처 사이의 중복을 확인하지 않으면 train과 test에 같은 서열이 들어간다. 정규화한 서열의 해시로 정확한 중복을, 동일성 검색으로 근접 중복을 찾는다(15.4장).

사전학습 데이터와의 중복. 단백질 언어모델은 대규모 서열 데이터베이스로 사전학습된다. 평가 서열이 그 데이터베이스에 들어 있었다면, 모델은 label 없이도 그 서열과 친척의 분포를 이미 학습했다(29.2장). label이 사전학습에 쓰이지 않았더라도, 평가 서열에 대한 정보가 표현에 들어간 것이다. 사전학습 데이터의 판과 목록을 확인해 평가 서열과의 최대 동일성을 보고하고, 확인할 수 없으면 한계로 적는다.

label의 순환. 데이터베이스의 기능 주석 중 상당수는 실험이 아니라 비슷한 서열에서 옮겨 온 추정이다(29.1장). 이런 label로 학습하고 평가하면, 서열 유사성 모델은 주석을 만든 과정을 재현하는 것만으로 높은 점수를 받는다. 실험 근거가 있는 label만으로 test를 구성하거나, 근거 종류별로 성능을 나누어 보고한다.

분포 이동. 벤치마크에는 많이 연구된 생물종과 단백질 가족이 많다. 실제로 모델을 쓰려는 대상(덜 연구된 가족, 환경 시료의 새 서열)은 분포가 다르다. 21.4장의 분포 이동 점검처럼, 평가 대상의 분포를 벤치마크와 비교하고 조건별 성능을 보고한다.

실험 검증

왜 필요한가. 계산 평가는 기존 데이터에 대한 회고적(retrospective) 평가다. 모델이 새로 제안한 서열·분자·구조가 실제로 작동하는지는 실험만이 답한다. 그리고 실험 결과를 어떻게 보고하느냐에 따라 같은 결과가 크게 다르게 읽힌다.

검증의 경로. 계산 예측은 보통 단계를 거쳐 검증된다.

  1. 계산 검증(in silico). 다른 모델이나 물리 기반 계산으로 예측을 교차 확인한다. 설계 모델과 검증 모델이 같은 데이터로 학습되었다면 서로의 편향을 공유하므로 독립적인 검증이 아니다(29.3장).
  2. 시험관 실험(in vitro). 정제한 분자나 세포로 결합, 활성, 안정성을 측정한다.
  3. 더 복잡한 계(세포, 생물체). 시험관에서의 활성이 실제 생물학적 맥락에서도 유지되는지 확인한다.

단계마다 비용이 커지고 처리량이 줄어든다. 모델의 역할은 대개 다음 단계로 보낼 후보를 고르는 것이다.

성공률의 보고. 실험 결과는 다음을 모두 포함해야 해석할 수 있다.

  • 분모. 몇 개를 실험했는가. "활성 후보 12개를 찾았다"는 96개 중 12개인지 12개 중 12개인지에 따라 전혀 다른 결과다.
  • 선택 과정. 실험한 후보를 어떻게 골랐는가(모델 점수 상위, 사람이 추가로 거름, 합성 가능한 것만). 사람이 거른 단계가 있으면 모델만의 성능이 아니다.
  • 비교 기준. 무작위 선택이나 기존 방법으로 고른 후보의 성공률. 원래 성공률이 높은 과제에서는 높은 성공률이 모델의 공이 아닐 수 있다.
  • 불확실성. 실험 수가 적으면 성공률의 신뢰구간이 넓다(12.4장).
  • 실패한 후보. 실패도 데이터다. 실패한 후보의 목록과 측정값을 함께 공개하면 다음 모델의 학습과 평가에 쓰인다.
from scipy.stats import binomtest, fisher_exact

tested = {"모델 추천": (12, 96), "무작위 선택": (3, 96)}   # (활성이 확인된 수, 실험한 수)
for name, (k, n) in tested.items():
    ci = binomtest(k, n).proportion_ci(confidence_level=0.95, method="exact")
    print(f"{name}: {k}/{n} = {k / n:.3f}, 95% 신뢰구간 [{ci.low:.3f}, {ci.high:.3f}]")
table = [[12, 96 - 12], [3, 96 - 3]]                   # 2x2 분할표: (성공, 실패) × (모델, 무작위)
print(f"Fisher 정확 검정(단측) p = {fisher_exact(table, alternative='greater')[1]:.3f}")
print(f"30개 중 0개 성공일 때 성공률의 단측 95% 상한: {1 - 0.05 ** (1 / 30):.3f}")
모델 추천: 12/96 = 0.125, 95% 신뢰구간 [0.066, 0.208]
무작위 선택: 3/96 = 0.031, 95% 신뢰구간 [0.006, 0.089]
Fisher 정확 검정(단측) p = 0.014
30개 중 0개 성공일 때 성공률의 단측 95% 상한: 0.095

모델이 고른 96개 중 12개(12.5%)가 활성이었고, 무작위로 고른 96개 중 3개(3.1%)가 활성이었다. 두 신뢰구간은 겹치고, 모델 쪽 구간의 하한은 6.6%다. Fisher 정확 검정의 단측 p-값은 0.014로, 이 실험만으로도 무작위보다 낫다는 증거는 있지만 "성공률 12.5%"라는 점 추정은 매우 불확실하다. 30개를 실험해 하나도 성공하지 못했을 때도 "성공률 0%"가 아니라 "9.5% 이하일 가능성이 높다"가 정확한 표현이다.

전향적 검증. 이미 있는 데이터로 하는 평가는 아무리 잘 나누어도 과거 데이터의 편향을 공유한다. 모델을 고정한 뒤 새로 실험해 확인하는 전향적(prospective) 검증이 가장 강한 근거다. 이때 모델, 후보 선택 규칙, 성공 기준을 실험 전에 기록해 두어야(사전 등록과 같은 원리, 23.4장) 결과를 본 뒤 기준을 바꾸는 일을 막을 수 있다.

재현성

왜 필요한가. 같은 코드를 다시 돌려도 다른 숫자가 나오면, 차이가 모델의 개선인지 데이터·소프트웨어·무작위성의 차이인지 알 수 없다. 생명과학 데이터는 특히 데이터베이스 판이 자주 바뀌어, 같은 이름의 데이터셋이 해마다 다른 내용을 가진다.

데이터 버전. 데이터베이스 이름과 판(release), 내려받은 날짜, 거른 기준을 기록한다. 가장 확실한 방법은 최종 데이터셋의 지문(fingerprint)을 남기는 것이다. 행을 정렬한 뒤 해시를 계산하면 행 순서와 무관하게 내용이 같은지 확인할 수 있다(15.5장).

import hashlib

AA = "ACDEFGHIKLMNPQRSTVWY"
ids = [f"P{i:04d}" for i in range(300)]

def fingerprint(ids, S, y):                            # 행 순서와 무관한 데이터셋 지문
    rows = sorted(f"{i}\t{''.join(AA[c] for c in s)}\t{l}" for i, s, l in zip(ids, S, y))
    return hashlib.sha256("\n".join(rows).encode()).hexdigest()[:12]

perm = rng.permutation(300)
y_fixed = y.copy()
y_fixed[17] = 1 - y_fixed[17]                          # label 하나를 고친 판
print("원본            :", fingerprint(ids, S, y))
print("행 순서만 섞음  :", fingerprint([ids[i] for i in perm], S[perm], y[perm]))
print("label 하나 수정 :", fingerprint(ids, S, y_fixed))
원본            : c3d51dd1fd2a
행 순서만 섞음  : c3d51dd1fd2a
label 하나 수정 : 09aac8ef411c

행 순서만 섞은 판은 지문이 같고, label 하나만 고친 판은 지문이 완전히 달라진다. 지문 12자리를 논문과 코드 저장소에 남기면, 다른 사람이 같은 데이터로 재현하고 있는지 바로 확인할 수 있다. train·validation·test 분할도 파일(서열 식별자 → 분할 이름)로 저장하고 그 지문을 함께 남긴다.

소프트웨어 버전. 정렬·군집 도구의 버전과 옵션(동일성 정의, 기준값, 군집 방식)은 분할을 바꾼다. 모델 코드의 버전, 사전학습 가중치의 판, 주요 라이브러리의 버전도 기록한다(22.2장).

무작위성. 분할, 초기화, 데이터 순서의 시드를 고정하고 기록한다. 시드 하나의 결과가 아니라 여러 시드(또는 여러 분할)의 평균과 흩어짐을 보고한다. 군집 분할은 군집 크기가 제각각이라 어느 군집이 test에 들어가느냐에 따라 성능이 크게 흔들린다.

보고 체크리스트. 생물학에서 지도학습을 보고할 때 데이터·최적화·모델·평가 네 영역에서 무엇을 밝혀야 하는지 정리한 권고안이 있다(DOME, Walsh 외, 2021). 자신의 보고서를 이 네 영역에 맞추어 점검하면 빠진 항목을 찾기 쉽다.

실습

같은 과제를 무작위 분할과 서열 동일성 기준 군집 분할로 평가해 성능 차이를 측정한다.

  1. 서열과 기능 label이 있는 데이터셋 하나를 고르고, 데이터베이스 판과 label의 근거(실험, 추정)를 기록한다.
  2. 서열 사이의 동일성을 계산하고(실제 서열은 정렬 도구 사용), 기준값 두세 개(예: 0.3, 0.5, 0.7)로 단일 연결 군집을 만든다. 군집 수와 가장 큰 군집의 크기를 기록한다.
  3. 무작위 분할과 각 기준값의 군집 분할에서 같은 모델, 같은 튜닝 예산으로 성능을 잰다. 분할마다 여러 시드로 반복한다.
  4. test 서열마다 train과의 최대 동일성을 계산하고, 동일성 구간별 성능을 표로 만든다.
  5. 사전학습 모델을 썼다면 사전학습 데이터와 test 서열의 중복을 점검한다.
  6. 이 모델의 예측을 실험으로 검증한다고 가정하고, 실험할 후보 수, 선택 규칙, 비교 기준, 성공 기준을 미리 정한 검증 계획을 쓴다.
  7. 최종 데이터셋과 분할 파일의 지문을 남긴다.

단일 연결 군집을 직접 구현한다.

실패 사례

  • 서열 유사도를 고려하지 않은 분할로 일반화를 주장하는 것. 무작위 분할의 점수는 가까운 친척을 베끼는 능력을 잰다. "새 단백질"에 대한 주장은 동일성 기준 군집 분할로만 뒷받침된다.
  • 탐욕적 군집으로 나누고 동일성 보장을 주장하는 것. 대표 서열 기준 군집은 다른 군집의 서열과 기준 이상으로 비슷할 수 있다. 분할 후 test–train 최대 동일성을 직접 계산해 확인한다.
  • 시간 분할을 새 가족에 대한 일반화로 해석하는 것. 새로 등록된 서열 대부분은 기존 가족의 구성원이다.
  • 성공 사례만 보고하는 것. 분모, 선택 과정, 비교 기준, 신뢰구간 없이 "활성 후보 발견"을 보고하면 결과를 해석할 수 없다.
  • 데이터베이스 판과 분할을 기록하지 않는 것. 판이 바뀌면 같은 코드도 다른 데이터에서 다른 점수를 낸다. 지문과 분할 파일을 남긴다.

통과 기준

  • 같은 과제에 대해 무작위 분할과 서열 동일성 기준 군집 분할(기준값과 군집 방식 명시)의 성능을 여러 시드로 보고하고, test–train 최대 동일성 구간별 성능 표를 만든다.
  • 후보 수·선택 규칙·비교 기준·성공 기준·신뢰구간 계산 방법을 포함한 실험 검증 계획을 쓴다.
  • 데이터베이스 판, 도구 버전과 옵션, 시드, 데이터셋과 분할 파일의 지문을 기록한다.
  • 연습문제 identity-cluster-split의 테스트를 통과한다.

참고 문헌

  • Steinegger, Söding (2017). MMseqs2 enables sensitive protein sequence searching for the analysis of massive data sets. Nature Biotechnology, 35(11), 1026–1028. — 대규모 단백질 서열 검색·군집 도구
  • Walsh 외 (2021). DOME: recommendations for supervised machine learning validation in biology. Nature Methods, 18(10), 1122–1127. — 생물학 지도학습 보고의 데이터·최적화·모델·평가 권고
  • Rao 외 (2019). Evaluating protein transfer learning with TAPE. NeurIPS 32. — 진화적으로 먼 단백질에 대한 일반화를 시험하는 평가 분할
  • Kapoor, Narayanan (2023). Leakage and the reproducibility crisis in machine-learning-based science. Patterns, 4(9), 100804. — 과학 분야 전반의 누수 사례와 유형