Proteomics

프로테오믹스 DE 결과에서 바이오마커를 자동 검증하는 파이프라인

Differential Expression 분석 결과에서 유의미한 단백질이 실제 바이오마커인지 자동으로 검증하는 validate_dep_biomarkers 도구를 개발한 경험을 공유한다.

·8 min read
#DE분석#바이오마커#파이프라인#프로테오믹스#자동검증#BioAI

Scientist analyzing protein data on multiple monitors

문제: DE 결과를 받고 나서 뭘 해야 하나

프로테오믹스 Differential Expression(DE) 분석을 돌리면, 수십에서 수백 개의 유의미한 DEP(Differentially Expressed Proteins)가 나온다. adjusted p < 0.05, |log2FC| > 1 기준으로 필터링해도 보통 50~200개는 된다.

문제는 그 다음이다. **"이 단백질들이 실제로 어떤 질병과 관련 있는 바이오마커인가?"**를 확인해야 한다. 하나하나 PubMed에서 검색하고, UniProt에서 기능을 확인하는 건 비현실적이었다. 50개 단백질을 수동으로 확인하면 반나절은 족히 걸렸다.

그래서 BioAI Market에 validate_dep_biomarkers라는 자동 검증 도구를 만들었다.

validate_dep_biomarkers 워크플로우

전체 워크플로우는 이렇다:

DE Results (CSV/TSV)
  → significant DEPs 추출 (adj.p < 0.05, |log2FC| > threshold)
  → biomarkers DB에서 매칭
  → disease associations 조회
  → matchRate 계산
  → therapeutic area별 분류
  → 결과 렌더링

DB 매칭 로직

매칭은 두 단계로 수행했다:

// 1단계: Exact match
const exactMatch = await supabase
  .from('biomarkers')
  .select('*, biomarker_diseases(*, diseases(*))')
  .ilike('name', proteinName);

// 2단계: Fuzzy matching (exact match 실패 시)
if (!exactMatch.data?.length) {
  const fuzzyMatch = await supabase
    .from('biomarkers')
    .select('*, biomarker_diseases(*, diseases(*))')
    .ilike('name', `%${proteinName}%`);
}

처음에는 exact match만 했는데, "EGFR"과 "Epidermal Growth Factor Receptor"가 매칭이 안 되는 문제가 있었다. DB에는 풀네임으로 저장되어 있고, DE 결과에는 약어로 나오는 경우가 많았다. fuzzy matching을 추가하니 매칭률이 25%에서 **42%**로 올라갔다.

matchRate 계산과 결과 구조

matchRate는 간단하게 계산했다:

interface ValidationResult {
  totalDEPs: number;        // 전체 유의미 DEP 수
  uniqueDEPs: number;       // 중복 제거 후 unique DEP 수
  matchedDEPs: number;      // DB에서 매칭된 DEP 수
  matchRate: number;         // matchedDEPs / uniqueDEPs × 100
  matches: BiomarkerMatch[];
  unmatchedProteins: string[];
}

const matchRate = (matchedDEPs / uniqueDEPs) * 100;
// 실제 테스트: 127개 DEP 중 53개 매칭 → matchRate = 41.7%

Therapeutic Area 분류

매칭된 바이오마커들을 therapeutic area별로 분류했다. 이게 연구자에게 가장 유용한 정보였다.

const therapeuticAreas: Record<string, BiomarkerMatch[]> = {};

for (const match of matches) {
  for (const assoc of match.diseases) {
    const area = assoc.disease.therapeutic_area || 'Unclassified';
    if (!therapeuticAreas[area]) therapeuticAreas[area] = [];
    therapeuticAreas[area].push(match);
  }
}

i18n도 적용했다. therapeutic_area가 영어로 저장되어 있어서 한글 번역 맵을 만들었다:

const therapeuticAreaKo: Record<string, string> = {
  'Oncology': '종양학',
  'Neurology': '신경학',
  'Cardiology': '심장학',
  'Immunology': '면역학',
  'Endocrinology': '내분비학',
  'Hepatology': '간장학',
  'Nephrology': '신장학',
};

템플릿 기반 렌더링: LLM의 수치 날조 방지

초기 버전에서는 검증 결과를 LLM에게 요약하라고 시켰다. 대참사였다.

// LLM이 생성한 결과 (환각)
"127개 DEP 중 78개가 알려진 바이오마커와 매칭되었습니다 (61.4%)"
// 실제 결과
"127개 DEP 중 53개 매칭 (41.7%)"

LLM이 matchRate를 20%나 뻥튀기했다. 이건 치명적이다. 연구 결과에 대한 해석이 완전히 달라진다.

해결책은 result-templates.ts였다. 모든 수치를 코드에서 계산하고, 템플릿에 삽입하는 방식이다:

// result-templates.ts
export function renderValidationResult(result: ValidationResult): string {
  return `## 바이오마커 검증 결과

| 항목 | 값 |
|------|-----|
| 전체 DEP 수 | ${result.totalDEPs} |
| Unique DEP 수 | ${result.uniqueDEPs} |
| 매칭된 바이오마커 | ${result.matchedDEPs} |
| 매칭률 | ${result.matchRate.toFixed(1)}% |

### Therapeutic Area별 분류
${renderTherapeuticAreas(result.matches)}
`;
}

이렇게 하니 LLM이 수치를 바꿀 수 없게 되었다. LLM은 템플릿 렌더링 결과를 그대로 전달하는 역할만 한다.

Dedup 처리: 같은 DEP가 여러 질병에 연관될 때

하나의 단백질이 여러 질병과 연관되는 경우가 빈번했다. 예를 들어 CEA(Carcinoembryonic Antigen)는 대장암, 폐암, 유방암, 위암, 췌장암 등 5개 이상의 암과 연관된다.

처음에는 모든 연관 질병을 나열했는데, 결과가 너무 길어졌다. 그래서 +N 표시를 도입했다:

function formatDiseaseList(diseases: Disease[], maxShow: number = 3): string {
  if (diseases.length <= maxShow) {
    return diseases.map(d => d.name).join(', ');
  }
  const shown = diseases.slice(0, maxShow).map(d => d.name).join(', ');
  return `${shown} (+${diseases.length - maxShow})`;
}

// 결과: "대장암, 폐암, 유방암 (+2)"

누락 바이오마커 발견과 DB 보강

실제 테스트를 돌리면서 예상치 못한 부산물이 있었다. DB에 없는 중요 바이오마커를 발견한 것이다.

unmatchedProteins 목록을 확인하니, PSA, CYFRA 21-1, TK1, PD-L1 같은 임상적으로 중요한 바이오마커들이 빠져 있었다. 이건 초기 DB 구축 때 놓친 것들이었다.

바로 INSERT로 추가했다:

INSERT INTO biomarkers (name, description, category, specimen_type, owner_id)
VALUES 
  ('PSA', 'Prostate-Specific Antigen, 전립선암 선별 바이오마커', 'Protein', 'Blood', NULL),
  ('CYFRA 21-1', 'Cytokeratin 19 Fragment, 비소세포폐암 바이오마커', 'Protein', 'Blood', NULL),
  ('TK1', 'Thymidine Kinase 1, 세포 증식 바이오마커', 'Enzyme', 'Blood', NULL),
  ('PD-L1', 'Programmed Death-Ligand 1, 면역관문 바이오마커', 'Protein', 'Tissue', NULL)
ON CONFLICT (name, owner_id) DO NOTHING;

ON CONFLICT (name, owner_id) DO NOTHING 패턴은 중복 삽입을 자동으로 무시한다. 같은 스크립트를 여러 번 실행해도 안전하다. PostgreSQL ON CONFLICT 문서에서 자세한 내용을 확인할 수 있다.

성능과 한계

현재 파이프라인의 처리 시간:

  • 127개 DEP 검증: 약 3.2초 (DB 쿼리 포함)
  • 500개 DEP 검증: 약 8.7초
  • 병목: fuzzy matching 시 ILIKE %...% 쿼리가 인덱스를 타지 못함

개선 방향으로는 trigram 인덱스(pg_trgm)를 적용하거나, 임베딩 기반 시맨틱 매칭으로 전환하는 것을 고려하고 있다.

PubMed의 최신 바이오마커 리뷰를 참고하면서 지속적으로 DB를 보강하고 있다. 데이터베이스는 살아있는 유기체처럼 계속 성장해야 한다.

💡 바이오마커 DB 구축의 전체 과정은 이전 글: 바이오마커 데이터베이스를 직접 만들어봤다에서 다루었다.

프로테오믹스 분석 워크플로우의 전체 그림은 sbmlab.com에서 확인할 수 있다.

관련 글