프로테오믹스 DE 결과에서 바이오마커를 자동 검증하는 파이프라인
Differential Expression 분석 결과에서 유의미한 단백질이 실제 바이오마커인지 자동으로 검증하는 validate_dep_biomarkers 도구를 개발한 경험을 공유한다.
문제: 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에서 확인할 수 있다.