Skip to content

3강: 개인 환자 유전체 기반 변이 해석

BBP WGS 분석 튜토리얼


학습 목표

  1. 변이 주석(Annotation) 결과 확인 및 해석 - ClinVar, 기능적 영향(Consequence) 기반 변이 주석
  2. 질환 관련 변이 선별 및 Variant Prioritization - 병원성 변이 필터링 및 우선순위화
  3. 암(Somatic)·희귀질환(Germline) 환자 해석 차이 비교 - 두 해석 전략의 실제 데이터 비교 분석

사용 데이터

데이터 루트: /tier4/DSC/jheepark/bbp-wgs-data/

Germline 트랙 (GRCh37)

데이터경로설명
1000 Genomes chr22 VCFindividual/ALL.chr22.phase3_shapeit2_mvncall_integrated_v5b.20130502.genotypes.vcf.gz2,504 샘플 joint VCF. 여기서 개인 샘플(NA18939, JPT)을 추출
ClinVar VCF (GRCh37)annotation/clinvar.vcf.gz변이의 임상적 병원성 분류 정보
통합 샘플 정보phenotype/integrated_sample_info.tsv1강에서 생성한 Panel + Pedigree 병합 데이터

Somatic 트랙 (GRCh38)

데이터경로설명
HCC1395 Somatic VCFindividual/somatic/HCC1395_somatic.chr22.vcf.gzSEQC2 consortium, 유방암 세포주 high-confidence somatic call set (chr22, 657개 변이)
ClinVar VCF (GRCh38)annotation/clinvar_GRCh38.vcf.gzGRCh38 기반 임상 병원성 정보
VEP cache (선택)annotation/vep/homo_sapiens/Docker 로컬 VEP 실행 시 필요. 최초 1회 ~15GB 다운로드

⚠️ Reference genome 차이: Germline 트랙은 GRCh37, Somatic 트랙은 GRCh38 입니다. 두 트랙을 각자의 reference 에 맞는 ClinVar 로 독립 annotation 한 뒤, 해석 결과 단계에서 비교합니다.

산출물 (본 강의에서 생성)

파일경로설명
환자 VCF (Germline)individual/patient_{SAMPLE}.vcf.gz단일 환자 변이만 추출
ClinVar 주석 VCF (Germline)individual/patient_{SAMPLE}.clinvar.vcf.gzClinVar INFO 주입
VEP 입력 VCFindividual/patient_{SAMPLE}.vep_input.vcf.gzVEP 실습용 소규모 변이 세트
Germline Prioritized TSVindividual/patient_{SAMPLE}.prioritized.tsv최종 Germline 후보 변이
Germline P/LP TSVindividual/patient_{SAMPLE}.germline_plp.tsvGermline Pathogenic/Likely pathogenic 변이
Somatic ClinVar VCFindividual/HCC1395_somatic.clinvar.chr22.vcf.gzClinVar GRCh38 주입
Somatic Prioritized TSVindividual/HCC1395_somatic.prioritized.tsvHighConf + Coding impact 변이

사용 도구

  • bcftools, tabix (VCF 주석 주입 및 필터링)
  • Ensembl VEP (Docker / Singularity / Conda 설치 + Ensembl REST API)
  • Python: cyvcf2, pandas, requests, matplotlib, seaborn

0. 환경 설정

python
import os
import tempfile

# /tmp 공간 부족 시 FileNotFoundError 방지: 쓰기 가능한 임시 디렉토리 확보
def _ensure_tmpdir():
    for cand in [os.environ.get('TMPDIR'), '/tmp', '/var/tmp',
                 os.path.expanduser('~/.cache/tmp')]:
        if not cand:
            continue
        try:
            os.makedirs(cand, exist_ok=True)
            with tempfile.NamedTemporaryFile(dir=cand, delete=True):
                pass
            return cand
        except (OSError, PermissionError):
            continue
    raise RuntimeError('사용 가능한 임시 디렉토리를 찾지 못했습니다.')

_tmp = _ensure_tmpdir()
os.environ['TMPDIR'] = _tmp
tempfile.tempdir = _tmp
print(f'임시 디렉토리: {_tmp}')

import subprocess
import re
from collections import Counter, defaultdict

import pandas as pd
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
from cyvcf2 import VCF
from matplotlib import font_manager
import warnings
warnings.filterwarnings('ignore')

# 시각화 설정 (seaborn 스타일을 먼저 설정해야 한글 폰트가 덮어씌워지지 않음)
sns.set_style('whitegrid')
sns.set_palette('Set2')

# ============================================================
# 한글 폰트 설정 — NanumGothic 등록 + fallback chain + 캐시 갱신
# ============================================================
_KR_FONT = 'NanumGothic'
_KR_CANDIDATES = [
    '/usr/share/fonts/truetype/nanum/NanumGothic.ttf',
    '/usr/share/fonts/truetype/nanum/NanumBarunGothic.ttf',
    '/usr/share/fonts/truetype/nanum/NanumSquareR.ttf',
]
_registered = False
for _fp in _KR_CANDIDATES:
    if os.path.exists(_fp):
        try:
            font_manager.fontManager.addfont(_fp)
            _KR_FONT = font_manager.FontProperties(fname=_fp).get_name()
            _registered = True
            print(f'한글 폰트 등록: {_KR_FONT} ({_fp})')
            break
        except Exception as e:
            print(f'폰트 등록 실패: {_fp} - {e}')

if not _registered:
    print('⚠ 한글 폰트 파일을 찾지 못했습니다. 한글이 깨질 수 있습니다.')

# 전역 rcParams: sans-serif 체인의 맨 앞에 한글 폰트
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.sans-serif'] = [_KR_FONT, 'DejaVu Sans', 'Liberation Sans', 'Arial']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['figure.figsize'] = (12, 6)
plt.rcParams['font.size'] = 12
# 이미지 크기 초과(ValueError: Image size too large) 방지 - dpi 고정
plt.rcParams['figure.dpi'] = 100
plt.rcParams['savefig.dpi'] = 100
plt.rcParams['figure.autolayout'] = False

def safe_top(series_or_dict, n=30):
    """barh 시각화 전 상위 n개만 남겨 이미지 높이 폭주(>2^16 px) 방지."""
    if hasattr(series_or_dict, 'head'):
        return series_or_dict.head(n)
    if isinstance(series_or_dict, dict):
        items = list(series_or_dict.items())[:n]
        return dict(items)
    return series_or_dict

# 데이터 경로
DATA_DIR = '/tier4/DSC/jheepark/bbp-wgs-data'
INDIVIDUAL_DIR = f'{DATA_DIR}/individual'
SOMATIC_DIR   = f'{DATA_DIR}/individual/somatic'
ANNOTATION_DIR = f'{DATA_DIR}/annotation'
PHENOTYPE_DIR = f'{DATA_DIR}/phenotype'

# Germline (GRCh37) - 1000 Genomes chr22 + 환자 1명 추출용
VCF_PATH     = f'{INDIVIDUAL_DIR}/ALL.chr22.phase3_shapeit2_mvncall_integrated_v5b.20130502.genotypes.vcf.gz'
CLINVAR_PATH = f'{ANNOTATION_DIR}/clinvar.vcf.gz'           # GRCh37

# Somatic (GRCh38) - HCC1395 유방암 세포주 high-confidence somatic VCF
SOMATIC_VCF      = f'{SOMATIC_DIR}/HCC1395_somatic.chr22.vcf.gz'
CLINVAR_GRCH38   = f'{ANNOTATION_DIR}/clinvar_GRCh38.vcf.gz'

# 실습용 출력 경로
WORK_DIR = f'{INDIVIDUAL_DIR}'

print('환경 설정 완료!')
print(f'[Germline] 개인 VCF : {VCF_PATH}')
print(f'[Germline] ClinVar  : {CLINVAR_PATH}   (GRCh37)')
print(f'[Somatic]  HCC1395  : {SOMATIC_VCF}')
print(f'[Somatic]  ClinVar  : {CLINVAR_GRCH38}   (GRCh38)')
임시 디렉토리: /tmp
한글 폰트 등록: NanumGothic (/usr/share/fonts/truetype/nanum/NanumGothic.ttf)
환경 설정 완료!
[Germline] 개인 VCF : /tier4/DSC/jheepark/bbp-wgs-data/individual/ALL.chr22.phase3_shapeit2_mvncall_integrated_v5b.20130502.genotypes.vcf.gz
[Germline] ClinVar  : /tier4/DSC/jheepark/bbp-wgs-data/annotation/clinvar.vcf.gz   (GRCh37)
[Somatic]  HCC1395  : /tier4/DSC/jheepark/bbp-wgs-data/individual/somatic/HCC1395_somatic.chr22.vcf.gz
[Somatic]  ClinVar  : /tier4/DSC/jheepark/bbp-wgs-data/annotation/clinvar_GRCh38.vcf.gz   (GRCh38)

1. 변이 주석(Annotation) 개요

1.1 Annotation이란?

VCF 파일 자체는 변이의 위치와 염기 서열만 알려줄 뿐, 그 변이가 어떤 유전자에 속하는지, 단백질에 어떤 영향을 주는지, 질환과 관련이 있는지 등의 임상적 해석 정보는 포함하지 않습니다.

Annotation은 외부 데이터베이스의 정보를 변이에 부착하여 해석 가능한 형태로 만드는 과정입니다.

주석 유형주요 도구/DB제공 정보
기능적 영향Ensembl VEP, SnpEffmissense, stop_gained, splice_site 등
임상적 병원성ClinVarPathogenic / Benign / VUS 등
인구집단 빈도gnomAD, 1000G대규모 코호트에서 관찰된 AF
기능 예측CADD, SIFT, PolyPhenin-silico 병원성 예측 점수

1.2 ClinVar 데이터 구조

ClinVar VCF의 주요 INFO 필드를 먼저 살펴봅니다.

python
# ClinVar 헤더에서 주요 INFO 필드만 추출
result = subprocess.run(
    ['bcftools', 'view', '-h', CLINVAR_PATH],
    capture_output=True, text=True
)

header_lines = result.stdout.strip().split('\n')

key_fields = ['CLNSIG', 'CLNDN', 'CLNREVSTAT', 'CLNVC', 'GENEINFO', 'MC', 'ORIGIN', 'RS']

print('=== ClinVar 주요 INFO 필드 ===')
for line in header_lines:
    if line.startswith('##INFO'):
        for k in key_fields:
            if f'ID={k},' in line:
                # 필드 이름과 Description 추출
                desc = re.search(r'Description="([^"]+)"', line)
                print(f'\n[{k}]')
                print(f'  {desc.group(1) if desc else line}')
                break
=== ClinVar 주요 INFO 필드 ===

[CLNDN]
  ClinVar's preferred disease name for the concept specified by disease identifiers in CLNDISDB

[CLNREVSTAT]
  ClinVar review status of germline classification for the Variation ID

[CLNSIG]
  Aggregate germline classification for this single variant; multiple values are separated by a vertical bar

[CLNVC]
  Variant type

[GENEINFO]
  Gene(s) for the variant reported as gene symbol:gene id. The gene symbol and id are delimited by a colon (:) and each pair is delimited by a vertical bar (|)

[MC]
  comma separated list of molecular consequence in the form of Sequence Ontology ID|molecular_consequence

[ORIGIN]
  Allele origin. One or more of the following values may be added: 0 - unknown; 1 - germline; 2 - somatic; 4 - inherited; 8 - paternal; 16 - maternal; 32 - de-novo; 64 - biparental; 128 - uniparental; 256 - not-tested; 512 - tested-inconclusive; 1073741824 - other

[RS]
  dbSNP ID (i.e. rs number)
python
# ClinVar chr22 변이 수 확인
result = subprocess.run(
    ['bcftools', 'view', '-H', '-r', '22', CLINVAR_PATH],
    capture_output=True, text=True
)
n_clinvar_chr22 = len([l for l in result.stdout.strip().split('\n') if l.strip()])
print(f'ClinVar chr22 변이 수: {n_clinvar_chr22:,}')

# 앞 3개 샘플
lines = result.stdout.strip().split('\n')[:3]
print('\n=== ClinVar chr22 변이 예시 ===')
for line in lines:
    fields = line.split('\t')
    chrom, pos, vid, ref, alt = fields[0], fields[1], fields[2], fields[3], fields[4]
    info = fields[7]
    # 주요 필드 추출
    clnsig = re.search(r'CLNSIG=([^;]+)', info)
    gene = re.search(r'GENEINFO=([^;:]+)', info)
    clndn = re.search(r'CLNDN=([^;]+)', info)
    print(f'\n  {chrom}:{pos} {ref}>{alt}  (ClinVar ID: {vid})')
    print(f'    Gene     : {gene.group(1) if gene else "-"}')
    print(f'    CLNSIG   : {clnsig.group(1) if clnsig else "-"}')
    print(f'    CLNDN    : {(clndn.group(1) if clndn else "-")[:80]}')
ClinVar chr22 변이 수: 95,486

=== ClinVar chr22 변이 예시 ===

  22:16258221 C>T  (ClinVar ID: 3216855)
    Gene     : POTEH
    CLNSIG   : Uncertain_significance
    CLNDN    : not_specified

  22:16258239 C>T  (ClinVar ID: 3423174)
    Gene     : POTEH
    CLNSIG   : Uncertain_significance
    CLNDN    : not_specified

  22:16258254 C>T  (ClinVar ID: 4570584)
    Gene     : POTEH
    CLNSIG   : Likely_benign
    CLNDN    : not_specified

2. 개인 환자 샘플 선택 및 변이 주석

2.1 분석 대상 환자 선택

1강에서 생성한 통합 데이터에서 EAS 환자 1명을 선택합니다. 실제 임상에서는 환자의 WGS 결과 VCF가 분석 대상이 됩니다.

python
# 통합 데이터 로드
integrated = pd.read_csv(f'{PHENOTYPE_DIR}/integrated_sample_info.tsv', sep='\t')

# 분석 대상 환자 선택 (JPT 일본인)
target_sample = integrated[integrated['pop'] == 'JPT']['sample'].values[0]

print(f'=== 분석 대상 환자 ===')
print(f'  Sample ID      : {target_sample}')
print(f'  Population     : JPT (Japanese in Tokyo)')
print(f'  Super Population: EAS (East Asian)')

# 해당 샘플만 추출한 VCF 생성
patient_vcf = f'{WORK_DIR}/patient_{target_sample}.vcf.gz'

subprocess.run(
    ['bcftools', 'view', '-s', target_sample, '--min-ac', '1',
     '-Oz', '-o', patient_vcf, VCF_PATH],
    capture_output=True, text=True, check=True
)
subprocess.run(['tabix', '-p', 'vcf', '-f', patient_vcf],
               capture_output=True, text=True, check=True)

# 환자 변이 수 확인
result = subprocess.run(['bcftools', 'view', '-H', patient_vcf],
                        capture_output=True, text=True)
n_patient = len([l for l in result.stdout.strip().split('\n') if l.strip()])
print(f'\n환자 변이 수 (chr22): {n_patient:,}')
print(f'환자 VCF 저장: {patient_vcf}')
=== 분석 대상 환자 ===
  Sample ID      : NA18939
  Population     : JPT (Japanese in Tokyo)
  Super Population: EAS (East Asian)

환자 변이 수 (chr22): 56,754
환자 VCF 저장: /tier4/DSC/jheepark/bbp-wgs-data/individual/patient_NA18939.vcf.gz

2.2 ClinVar annotation 주입

bcftools annotate 를 이용해 환자 VCF에 ClinVar 정보를 추가합니다. 동일 position & REF/ALT 가 일치하는 변이에 대해 CLNSIG, CLNDN, GENEINFO 등이 부착됩니다.

python
# bcftools annotate 로 ClinVar INFO 주입
annotated_vcf = f'{WORK_DIR}/patient_{target_sample}.clinvar.vcf.gz'

# INFO 필드 선택적으로 가져오기
cmd = [
    'bcftools', 'annotate',
    '-a', CLINVAR_PATH,
    '-c', 'INFO/CLNSIG,INFO/CLNDN,INFO/CLNREVSTAT,INFO/GENEINFO,INFO/MC,INFO/ORIGIN',
    '-Oz', '-o', annotated_vcf,
    patient_vcf
]
result = subprocess.run(cmd, capture_output=True, text=True)
subprocess.run(['tabix', '-p', 'vcf', '-f', annotated_vcf],
               capture_output=True, text=True)

print('ClinVar annotation 완료')
print(f'출력 파일: {annotated_vcf}')

# 주입된 변이 수 확인 (CLNSIG가 있는 것만)
result = subprocess.run(
    ['bcftools', 'view', '-i', 'INFO/CLNSIG!="."', '-H', annotated_vcf],
    capture_output=True, text=True
)
n_annotated = len([l for l in result.stdout.strip().split('\n') if l.strip()])
print(f'\n환자 변이 {n_patient:,}개 중 ClinVar 주석이 부착된 변이: {n_annotated:,} ({n_annotated/n_patient*100:.2f}%)')
ClinVar annotation 완료
출력 파일: /tier4/DSC/jheepark/bbp-wgs-data/individual/patient_NA18939.clinvar.vcf.gz

환자 변이 56,754개 중 ClinVar 주석이 부착된 변이: 1,008 (1.78%)

2.3 Ensembl VEP 설치 및 간단한 Annotation 실습

Ensembl VEP (Variant Effect Predictor) 는 변이의 기능적 결과(consequence) 를 예측하는 표준 도구입니다. ClinVar 가 "이 변이가 어떤 질환인가"에 답한다면, VEP 는 "이 변이가 어느 유전자의 어떤 위치에 있으며 단백질을 어떻게 바꾸는가" 에 답합니다.

설치 방법 (3가지 중 택일)

방법장점단점
Docker환경 충돌 없음, 빠른 설치Docker 권한 필요
SingularityHPC 환경 권장, sudo 불필요이미지 빌드 시간
Conda/Source플러그인 튜닝 용이Perl 의존성 많음
① Docker 설치 (권장)
bash
# VEP Docker 이미지 pull
docker pull ensemblorg/ensembl-vep:release_110.1

# VEP cache/data 보관 디렉토리
VEP_DATA=/tier4/DSC/jheepark/bbp-wgs-data/annotation/vep
mkdir -p ${VEP_DATA}
chmod 777 ${VEP_DATA}    # 컨테이너 내부 사용자가 쓰기 가능하도록

# GRCh37 cache + fasta 다운로드 (최초 1회, ~15GB)
docker run -t -i --rm \
    -v ${VEP_DATA}:/opt/vep/.vep \
    ensemblorg/ensembl-vep:release_110.1 \
    INSTALL.pl -a cf -s homo_sapiens -y GRCh37 --NO_UPDATE
② Singularity 설치 (HPC 환경)
bash
singularity pull vep.sif docker://ensemblorg/ensembl-vep:release_110.1
singularity exec -B ${VEP_DATA}:/opt/vep/.vep vep.sif \
    INSTALL.pl -a cf -s homo_sapiens -y GRCh37 --NO_UPDATE
③ Conda 설치
bash
conda create -n vep -c bioconda ensembl-vep=110 -y
conda activate vep
vep_install -a cf -s homo_sapiens -y GRCh37 -c ${VEP_DATA} --NO_UPDATE
설치 확인
bash
docker run --rm ensemblorg/ensembl-vep:release_110.1 vep --help | head -5

VEP 주요 Consequence 범주

ImpactConsequence설명
HIGHstop_gained, frameshift_variant, splice_acceptor_variant, splice_donor_variant단백질 기능 크게 망가짐
MODERATEmissense_variant, inframe_insertion, inframe_deletion, protein_altering_variant아미노산 변화
LOWsynonymous_variant, splice_region_variant서열 보존되나 주변 영향
MODIFIERintron_variant, intergenic_variant, 5/3_prime_UTR_variant비-코딩 영역

간단한 VEP Annotation 실습

VEP cache 다운로드(~15GB)에는 시간이 많이 소요되므로, 이 튜토리얼에서는 두 가지 경량 실습을 수행합니다.

  1. 입력 VCF 준비 — 환자 VCF에서 ClinVar Pathogenic 변이 5개만 추출
  2. Docker VEP 명령어 확인 — 로컬에 VEP가 설치되어 있으면 바로 실행
  3. Ensembl REST API 실습 — 네트워크만 되면 즉시 annotation (cache 불필요)
python
# VEP 입력용 소규모 VCF 생성
# 환자는 common 집단이라 Pathogenic 변이가 없을 수 있으므로
# 의미있는 ClinVar 주석(Pathogenic / drug_response / risk_factor / Conflicting 등)
# 이 부착된 변이 중 앞 5개를 선택
vep_input_vcf = f'{WORK_DIR}/patient_{target_sample}.vep_input.vcf.gz'

cmd = ['bcftools', 'view', '-i', 'INFO/CLNSIG!="."', annotated_vcf]
result = subprocess.run(cmd, capture_output=True, text=True)
lines = result.stdout.split('\n')

header = [l for l in lines if l.startswith('#')]
body_all = [l for l in lines if l and not l.startswith('#')]

# 우선순위: Pathogenic > drug_response > risk_factor > Conflicting > Uncertain > 기타
def priority(line):
    info = line.split('\t')[7]
    if 'CLNSIG=Pathogenic' in info and 'Benign' not in info: return 1
    if 'Likely_pathogenic' in info and 'Benign' not in info: return 2
    if 'drug_response' in info:      return 3
    if 'risk_factor'   in info:      return 4
    if 'Conflicting'   in info:      return 5
    if 'Uncertain'     in info:      return 6
    return 9

body_sorted = sorted(body_all, key=priority)
body = body_sorted[:5]

# 쓰기 + bgzip + tabix
plain_path = f'{WORK_DIR}/patient_{target_sample}.vep_input.vcf'
with open(plain_path, 'w') as f:
    f.write('\n'.join(header + body) + '\n')

subprocess.run(['bgzip', '-f', plain_path], check=True)
subprocess.run(['tabix', '-p', 'vcf', '-f', vep_input_vcf], check=True)

print(f'VEP 입력 VCF 생성: {vep_input_vcf}')
print(f'변이 수: {len(body)}')
print('\n=== 입력 변이 미리보기 ===')
for line in body:
    fields = line.split('\t')
    gene   = re.search(r'GENEINFO=([^;:]+)', fields[7])
    clnsig = re.search(r'CLNSIG=([^;]+)', fields[7])
    print(f'  {fields[0]}:{fields[1]} {fields[3]}>{fields[4]}  '
          f'Gene={gene.group(1) if gene else "-"}  '
          f'CLNSIG={clnsig.group(1) if clnsig else "-"}')
VEP 입력 VCF 생성: /tier4/DSC/jheepark/bbp-wgs-data/individual/patient_NA18939.vep_input.vcf.gz
변이 수: 5

=== 입력 변이 미리보기 ===
  22:19956781 G>A  Gene=ARVCF  CLNSIG=drug_response
  22:19957023 C>T  Gene=ARVCF  CLNSIG=drug_response
  22:42522312 T>C  Gene=CYP2D6  CLNSIG=drug_response
  22:42522392 G>A  Gene=CYP2D6  CLNSIG=drug_response
  22:42523209 T>C  Gene=CYP2D6  CLNSIG=drug_response
방법 A — Docker 로 VEP 로컬 실행 (cache 설치된 경우)
bash
# 실행 전 조건
# - Docker 이미지: ensemblorg/ensembl-vep:release_110.1
# - Cache: ${VEP_DATA}/homo_sapiens/110_GRCh37/

PATIENT_DIR=/tier4/DSC/jheepark/bbp-wgs-data/individual
VEP_DATA=/tier4/DSC/jheepark/bbp-wgs-data/annotation/vep

docker run --rm \
    -v ${PATIENT_DIR}:/data \
    -v ${VEP_DATA}:/opt/vep/.vep \
    ensemblorg/ensembl-vep:release_110.1 \
    vep \
      --input_file  /data/patient_NA18939.vep_input.vcf.gz \
      --output_file /data/patient_NA18939.vep.tsv \
      --cache --offline --assembly GRCh37 \
      --symbol --canonical --biotype \
      --sift b --polyphen b \
      --tab --force_overwrite

아래 셀은 Docker + cache 가 준비된 환경에서만 정상 작동합니다. 가용하지 않으면 건너뛰고 방법 B (REST API) 로 진행하세요.

python
# Docker + VEP cache 환경 여부 확인 후 실행
VEP_DATA = f'{ANNOTATION_DIR}/vep'
vep_cache_exists = os.path.isdir(f'{VEP_DATA}/homo_sapiens')
docker_exists = subprocess.run(['which', 'docker'], capture_output=True).returncode == 0

vep_tsv = f'{WORK_DIR}/patient_{target_sample}.vep.tsv'

if docker_exists and vep_cache_exists:
    cmd = [
        'docker', 'run', '--rm',
        '-v', f'{WORK_DIR}:/data',
        '-v', f'{VEP_DATA}:/opt/vep/.vep',
        'ensemblorg/ensembl-vep:release_110.1',
        'vep',
        '--input_file',  f'/data/patient_{target_sample}.vep_input.vcf.gz',
        '--output_file', f'/data/patient_{target_sample}.vep.tsv',
        '--cache', '--offline', '--assembly', 'GRCh37',
        '--symbol', '--canonical', '--biotype',
        '--sift', 'b', '--polyphen', 'b',
        '--tab', '--force_overwrite'
    ]
    print('[VEP Docker 실행 중...]')
    r = subprocess.run(cmd, capture_output=True, text=True)
    print(f'rc={r.returncode}')
    if r.returncode == 0:
        # TSV 파싱
        vep_df = pd.read_csv(vep_tsv, sep='\t', comment='#', header=None)
        print(f'VEP annotation 수: {len(vep_df)} 행')
        print(vep_df.head())
    else:
        print(r.stderr[:500])
else:
    missing = []
    if not docker_exists: missing.append('docker CLI')
    if not vep_cache_exists: missing.append(f'VEP cache ({VEP_DATA}/homo_sapiens)')
    print(f'[SKIP] 실행 조건 미충족: {", ".join(missing)}')
    print('→ 아래 방법 B (REST API) 로 진행하세요.')
[SKIP] 실행 조건 미충족: VEP cache (/tier4/DSC/jheepark/bbp-wgs-data/annotation/vep/homo_sapiens)
→ 아래 방법 B (REST API) 로 진행하세요.
방법 B — Ensembl REST API 로 즉시 Annotation (cache 불필요)

Ensembl 은 VEP 엔진을 REST API 로 공개합니다. 네트워크만 되면 누구나 사용 가능하며, 입력 변이를 chr pos . ref alt . . . 형식 (VCF-style) 으로 보내면 VEP 와 동일한 consequence 를 반환합니다.

  • 엔드포인트 (GRCh37): https://grch37.rest.ensembl.org/vep/human/region
  • 엔드포인트 (GRCh38): https://rest.ensembl.org/vep/human/region
  • 요율 제한: 15 req/sec, per request ≤ 200 variants
python
import json
import requests

# REST API 입력 준비 - VCF-style 문자열 리스트
variants_for_rest = []
for line in body:
    fields = line.split('\t')
    chrom, pos, vid, ref, alt = fields[0], fields[1], fields[2], fields[3], fields[4]
    # REST API 형식: "chr pos id ref alt . . ."
    variants_for_rest.append(f'{chrom} {pos} {vid if vid != "." else "variant"} {ref} {alt} . . .')

print(f'REST API 로 보낼 변이 수: {len(variants_for_rest)}')
for v in variants_for_rest:
    print(f'  {v}')

# Ensembl REST API 호출 (GRCh37)
url = 'https://grch37.rest.ensembl.org/vep/human/region'
headers = {'Content-Type': 'application/json', 'Accept': 'application/json'}
payload = {'variants': variants_for_rest}

try:
    r = requests.post(url, headers=headers, data=json.dumps(payload), timeout=30)
    r.raise_for_status()
    vep_results = r.json()
    print(f'\n✓ VEP REST API 응답 수신 - {len(vep_results)} 변이')
except Exception as e:
    print(f'\n✗ REST API 호출 실패: {e}')
    print('  네트워크 상태를 확인하거나 방법 A(로컬 VEP) 를 사용하세요.')
    vep_results = []
REST API 로 보낼 변이 수: 5
  22 19956781 variant G A . . .
  22 19957023 variant C T . . .
  22 42522312 variant T C . . .
  22 42522392 variant G A . . .
  22 42523209 variant T C . . .

✓ VEP REST API 응답 수신 - 5 변이
python
# VEP REST API 응답 파싱 - 변이별 most_severe_consequence + 대표 transcript 정보
vep_rows = []
for res in vep_results:
    # 변이 기본 정보
    seq_region = res.get('seq_region_name')
    start      = res.get('start')
    input_str  = res.get('input', '')
    most_severe = res.get('most_severe_consequence')
    # 가장 영향이 큰 transcript consequence 선택 (canonical 우선)
    tc_list = res.get('transcript_consequences', [])
    canonical = [t for t in tc_list if t.get('canonical') == 1]
    best = canonical[0] if canonical else (tc_list[0] if tc_list else {})

    vep_rows.append({
        'input': input_str,
        'region': f'{seq_region}:{start}',
        'most_severe_consequence': most_severe,
        'gene_symbol': best.get('gene_symbol'),
        'transcript_id': best.get('transcript_id'),
        'impact': best.get('impact'),
        'biotype': best.get('biotype'),
        'canonical': best.get('canonical', 0),
        'hgvsc': best.get('hgvsc'),
        'hgvsp': best.get('hgvsp'),
        'sift_prediction':     best.get('sift_prediction'),
        'polyphen_prediction': best.get('polyphen_prediction'),
    })

vep_rest_df = pd.DataFrame(vep_rows)
print(f'VEP REST 결과 ({len(vep_rest_df)} 변이)\n')
cols_show = ['region', 'most_severe_consequence', 'impact', 'gene_symbol',
             'hgvsp', 'sift_prediction', 'polyphen_prediction']
print(vep_rest_df[cols_show].to_string(index=False))
VEP REST 결과 (5 변이)

     region most_severe_consequence   impact gene_symbol hgvsp sift_prediction polyphen_prediction
22:19956781     3_prime_UTR_variant MODIFIER        COMT  None            None                None
22:19957023     3_prime_UTR_variant MODIFIER        COMT  None            None                None
22:42522312          intron_variant MODIFIER      CYP2D6  None            None                None
22:42522392          intron_variant MODIFIER      CYP2D6  None            None                None
22:42523209          intron_variant MODIFIER      CYP2D6  None            None                None
VEP 결과와 ClinVar 결과 비교

VEP 의 Consequence/Impact 는 변이의 기능적 영향을, ClinVar 의 CLNSIG임상적 병원성을 나타냅니다. 두 정보를 결합해 보면 해석의 신뢰도가 크게 올라갑니다.

python
# VEP REST 결과와 ClinVar 주석을 변이 단위로 결합
if len(vep_rest_df) == 0:
    print('VEP REST 결과가 없어 비교를 건너뜁니다.')
else:
    # body 에 들어있는 ClinVar 주석을 파싱 - 같은 순서로 매칭
    clinvar_rows = []
    for line in body:
        fields = line.split('\t')
        info = fields[7]
        gene   = re.search(r'GENEINFO=([^;:]+)', info)
        clnsig = re.search(r'CLNSIG=([^;]+)', info)
        clndn  = re.search(r'CLNDN=([^;]+)', info)
        clinvar_rows.append({
            'region': f'{fields[0]}:{fields[1]}',
            'clinvar_gene': gene.group(1) if gene else None,
            'clinvar_CLNSIG': clnsig.group(1) if clnsig else None,
            'clinvar_disease': (clndn.group(1) if clndn else '').replace('_', ' ')[:60],
        })
    clinvar_df = pd.DataFrame(clinvar_rows)

    # region 기반 merge
    merged = vep_rest_df.merge(clinvar_df, on='region', how='left')

    print('=== VEP + ClinVar 통합 해석 ===\n')
    for _, row in merged.iterrows():
        print(f'[{row["region"]}]')
        print(f'  VEP consequence : {row["most_severe_consequence"]} ({row["impact"]})')
        print(f'  VEP gene / HGVS : {row["gene_symbol"]}  {row["hgvsp"] or row["hgvsc"] or ""}')
        print(f'  VEP in-silico   : SIFT={row["sift_prediction"]}, PolyPhen={row["polyphen_prediction"]}')
        print(f'  ClinVar CLNSIG  : {row["clinvar_CLNSIG"]}')
        print(f'  ClinVar disease : {row["clinvar_disease"]}')
        print()
=== VEP + ClinVar 통합 해석 ===

[22:19956781]
  VEP consequence : 3_prime_UTR_variant (MODIFIER)
  VEP gene / HGVS : COMT  
  VEP in-silico   : SIFT=None, PolyPhen=None
  ClinVar CLNSIG  : drug_response
  ClinVar disease : Tramadol response

[22:19957023]
  VEP consequence : 3_prime_UTR_variant (MODIFIER)
  VEP gene / HGVS : COMT  
  VEP in-silico   : SIFT=None, PolyPhen=None
  ClinVar CLNSIG  : drug_response
  ClinVar disease : Tramadol response

[22:42522312]
  VEP consequence : intron_variant (MODIFIER)
  VEP gene / HGVS : CYP2D6  
  VEP in-silico   : SIFT=None, PolyPhen=None
  ClinVar CLNSIG  : drug_response
  ClinVar disease : Tramadol response

[22:42522392]
  VEP consequence : intron_variant (MODIFIER)
  VEP gene / HGVS : CYP2D6  
  VEP in-silico   : SIFT=None, PolyPhen=None
  ClinVar CLNSIG  : drug_response
  ClinVar disease : Tramadol response

[22:42523209]
  VEP consequence : intron_variant (MODIFIER)
  VEP gene / HGVS : CYP2D6  
  VEP in-silico   : SIFT=None, PolyPhen=None
  ClinVar CLNSIG  : drug_response
  ClinVar disease : Tramadol response

3. 주석 결과 파싱 및 해석

cyvcf2 로 annotated VCF 를 순회하며 CLNSIG / GENEINFO / MC 정보를 추출합니다.

python
# 주석된 환자 변이 파싱
records = []

vcf = VCF(annotated_vcf)
for v in vcf:
    clnsig = v.INFO.get('CLNSIG')
    if clnsig is None:
        continue
    gene = v.INFO.get('GENEINFO')
    gene_sym = gene.split(':')[0] if gene else None
    clndn = v.INFO.get('CLNDN')
    revstat = v.INFO.get('CLNREVSTAT')
    mc = v.INFO.get('MC')
    # MC 의 첫 consequence 만 추출 (e.g. "SO:0001583|missense_variant")
    consequence = None
    if mc:
        for token in mc.split(','):
            if '|' in token:
                consequence = token.split('|')[1]
                break
    origin = v.INFO.get('ORIGIN')

    # 환자의 genotype
    gt = v.genotypes[0] if len(v.genotypes) else [0, 0, False]
    alt_count = sum(1 for a in gt[:2] if a == 1)
    zygosity = 'hom_alt' if alt_count == 2 else ('het' if alt_count == 1 else 'hom_ref')

    records.append({
        'CHROM': v.CHROM,
        'POS': v.POS,
        'REF': v.REF,
        'ALT': v.ALT[0] if v.ALT else '.',
        'rsID': v.ID,
        'GENE': gene_sym,
        'CLNSIG': clnsig,
        'CLNDN': clndn,
        'CLNREVSTAT': revstat,
        'CONSEQUENCE': consequence,
        'ORIGIN': origin,
        'ZYGOSITY': zygosity,
    })
vcf.close()

ann_df = pd.DataFrame(records)
print(f'ClinVar 주석이 부착된 환자 변이: {len(ann_df):,}개')
ann_df.head(10)
ClinVar 주석이 부착된 환자 변이: 1,008개
CHROMPOSREFALTrsIDGENECLNSIGCLNDNCLNREVSTATCONSEQUENCEORIGINZYGOSITY
02217565888CANoneIL17RABenignnot_provided|not_specifiedcriteria_provided,_multiple_submitters,_no_con...None1het
12217565932TCNoneIL17RABenignnot_specified|not_providedcriteria_provided,_multiple_submitters,_no_con...5_prime_UTR_variant1hom_alt
22217565974GCNoneIL17RABenignImmunodeficiency_51|not_specified|not_providedcriteria_provided,_multiple_submitters,_no_con...5_prime_UTR_variant1hom_alt
32217566206GTNoneIL17RABenignnot_specifiedcriteria_provided,_single_submitterintron_variant1hom_alt
42217566229TANoneIL17RABenignnot_specifiedcriteria_provided,_single_submitterintron_variant1hom_alt
52217582877GTNoneIL17RABenign/Likely_benignnot_specified|not_provided|Immunodeficiency_51criteria_provided,_multiple_submitters,_no_con...intron_variant1het
62217586471CTNoneIL17RABenignnot_provided|Immunodeficiency_51|not_specifiedcriteria_provided,_multiple_submitters,_no_con...intron_variant1het
72217586583CGNoneIL17RABenignnot_specifiedcriteria_provided,_single_submitterintron_variant1het
82217586631GANoneIL17RABenignnot_specifiedcriteria_provided,_single_submitterintron_variant1het
92217589516CTNoneIL17RAConflicting_classifications_of_pathogenicityImmunodeficiency_51criteria_provided,_conflicting_classificationssynonymous_variant1het

3.1 CLNSIG (병원성 분류) 분포

python
# CLNSIG 정규화 - 복합 값 처리
def normalize_clnsig(sig):
    if not sig:
        return 'Unknown'
    s = sig.replace('_', ' ')
    # 주요 카테고리 매핑
    if 'Pathogenic' in s and 'Conflicting' not in s:
        if 'Likely' in s:
            return 'Likely pathogenic'
        return 'Pathogenic'
    if 'Benign' in s and 'Conflicting' not in s:
        if 'Likely' in s:
            return 'Likely benign'
        return 'Benign'
    if 'Uncertain' in s:
        return 'VUS (Uncertain significance)'
    if 'Conflicting' in s:
        return 'Conflicting interpretations'
    if 'risk factor' in s.lower():
        return 'Risk factor'
    if 'drug response' in s.lower():
        return 'Drug response'
    return 'Other'

ann_df['CLNSIG_group'] = ann_df['CLNSIG'].apply(normalize_clnsig)

sig_counts = ann_df['CLNSIG_group'].value_counts()
print('=== 환자 변이의 CLNSIG 분포 ===')
for k, v in sig_counts.items():
    print(f'  {k:35s}: {v:>6,}')
=== 환자 변이의 CLNSIG 분포 ===
  Benign                             :    946
  Drug response                      :     22
  Other                              :     20
  Likely benign                      :      9
  VUS (Uncertain significance)       :      5
  Conflicting interpretations        :      4
  Risk factor                        :      2
python
# CLNSIG 분포 시각화
fig, axes = plt.subplots(1, 2, figsize=(16, 6), dpi=100)

# 색상 (병원성 → 경증)
sig_order = ['Pathogenic', 'Likely pathogenic', 'Conflicting interpretations',
             'VUS (Uncertain significance)', 'Risk factor', 'Drug response',
             'Likely benign', 'Benign', 'Other']
sig_colors = {
    'Pathogenic': '#c0392b',
    'Likely pathogenic': '#e74c3c',
    'Conflicting interpretations': '#e67e22',
    'VUS (Uncertain significance)': '#f1c40f',
    'Risk factor': '#9b59b6',
    'Drug response': '#8e44ad',
    'Likely benign': '#27ae60',
    'Benign': '#16a085',
    'Other': '#95a5a6',
}
present = [s for s in sig_order if s in sig_counts.index]
vals = [sig_counts[s] for s in present]
colors = [sig_colors[s] for s in present]

bars = axes[0].barh(present, vals, color=colors, edgecolor='black', linewidth=0.5)
axes[0].set_title('환자 변이의 ClinVar CLNSIG 분포', fontsize=14, fontweight='bold')
axes[0].set_xlabel('변이 수')
axes[0].invert_yaxis()
for bar, val in zip(bars, vals):
    axes[0].text(bar.get_width() + max(vals)*0.01, bar.get_y() + bar.get_height()/2,
                 f'{val:,}', va='center', fontweight='bold', fontsize=10)

# Consequence 분포 (상위 10개)
cons_counts = ann_df['CONSEQUENCE'].dropna().value_counts().head(10)
axes[1].barh(cons_counts.index[::-1], cons_counts.values[::-1],
             color='#3498db', edgecolor='black', linewidth=0.5)
axes[1].set_title('Molecular Consequence (상위 10개)', fontsize=14, fontweight='bold')
axes[1].set_xlabel('변이 수')

fig.tight_layout()
plt.show()
plt.close(fig)

png

3.2 Review Status (증거 수준) 해석

ClinVar의 CLNREVSTAT 는 분류의 근거 강도를 나타냅니다. 높은 신뢰도의 변이를 우선 검토해야 합니다.

Review StatusStars설명
practice_guideline★★★★임상 지침
reviewed_by_expert_panel★★★전문가 패널 리뷰
criteria_provided,_multiple_submitters,_no_conflicts★★여러 제출자, 충돌 없음
criteria_provided,_single_submitter단일 제출자
no_assertion_criteria_provided(없음)근거 부족
python
# Review Status 별 분포
rev_counts = ann_df['CLNREVSTAT'].value_counts().head(8)
print('=== 환자 변이의 Review Status 분포 ===')
for k, v in rev_counts.items():
    print(f'  {str(k)[:55]:55s}: {v:>6,}')

# 병원성 변이만 Review Status 별로 확인
path_df = ann_df[ann_df['CLNSIG_group'].isin(['Pathogenic', 'Likely pathogenic'])]
print(f'\n=== Pathogenic/Likely pathogenic 변이 ({len(path_df)}개)의 Review Status ===')
print(path_df['CLNREVSTAT'].value_counts())
=== 환자 변이의 Review Status 분포 ===
  criteria_provided,_multiple_submitters,_no_conflicts   :    633
  criteria_provided,_single_submitter                    :    330
  no_assertion_criteria_provided                         :     39
  criteria_provided,_conflicting_classifications         :      4
  reviewed_by_expert_panel                               :      1
  no_classification_provided                             :      1

=== Pathogenic/Likely pathogenic 변이 (0개)의 Review Status ===
Series([], Name: count, dtype: int64)

4. 질환 관련 변이 선별 및 Variant Prioritization

임상 해석에서는 수만 개의 변이 중 실제로 질환과 관련될 가능성이 높은 소수의 후보 변이를 골라내는 것이 핵심입니다.

4.1 Prioritization 일반 전략

일반적인 우선순위화 파이프라인은 다음 필터를 단계적으로 적용합니다:

  1. Quality filter — PASS 변이, 적절한 depth/GQ
  2. Population frequency filter — Rare variant (AF < 1%)
  3. Functional impact filter — HIGH/MODERATE consequence
  4. Clinical database filter — ClinVar Pathogenic/Likely pathogenic
  5. Gene panel filter — 해당 질환 관련 유전자 목록
python
# 단계별 필터링 파이프라인 (환자 전체 변이 기준)
steps = {}

# Step 0: 전체 환자 변이
steps['0. 전체 환자 변이'] = n_patient

# Step 1: ClinVar 주석 있는 변이
steps['1. ClinVar 주석 있음'] = len(ann_df)

# Step 2: Rare variant (ClinVar 변이 중 COMMON=0 또는 RS 기반 - 여기서는 AF_TGP 확인 불가하므로 개념 유지)
# 간략화: Benign/Likely benign 제외
step2 = ann_df[~ann_df['CLNSIG_group'].isin(['Benign', 'Likely benign'])]
steps['2. Benign/LB 제외'] = len(step2)

# Step 3: Functional impact (coding 영역)
coding_consequences = {
    'missense_variant', 'stop_gained', 'stop_lost', 'start_lost',
    'frameshift_variant', 'inframe_insertion', 'inframe_deletion',
    'splice_acceptor_variant', 'splice_donor_variant', 'splice_region_variant',
    'nonsense', 'initiator_codon_variant'
}
step3 = step2[step2['CONSEQUENCE'].isin(coding_consequences)]
steps['3. Coding impact만'] = len(step3)

# Step 4: Pathogenic/Likely pathogenic
step4 = step3[step3['CLNSIG_group'].isin(['Pathogenic', 'Likely pathogenic'])]
steps['4. P / LP'] = len(step4)

# Step 5: Review status ≥ 1 star (criteria_provided)
step5 = step4[step4['CLNREVSTAT'].fillna('').str.contains('criteria_provided', na=False)]
steps['5. ≥ 1 star'] = len(step5)

print('=== 단계별 Variant Prioritization 결과 ===')
for name, count in steps.items():
    print(f'  {name:30s}: {count:>10,}')

prioritized = step5.copy()
print(f'\n→ 최종 우선순위 변이: {len(prioritized)}개')
=== 단계별 Variant Prioritization 결과 ===
  0. 전체 환자 변이                   :     56,754
  1. ClinVar 주석 있음              :      1,008
  2. Benign/LB 제외               :         53
  3. Coding impact만             :          6
  4. P / LP                     :          0
  5. ≥ 1 star                   :          0

→ 최종 우선순위 변이: 0개
python
# 필터링 파이프라인 시각화 (funnel)
_steps_safe = safe_top(steps, n=30)
fig, ax = plt.subplots(figsize=(12, 6), dpi=100)

names = list(_steps_safe.keys())
counts = list(_steps_safe.values())
colors = plt.cm.Reds_r(np.linspace(0.1, 0.8, max(len(names), 1)))

bars = ax.barh(names, counts, color=colors, edgecolor='black', linewidth=0.5)
ax.set_title('Variant Prioritization Funnel', fontsize=14, fontweight='bold')
ax.set_xlabel('변이 수 (log scale)')
ax.set_xscale('symlog')
ax.invert_yaxis()

for bar, val in zip(bars, counts):
    if val > 0:
        ax.text(bar.get_width() * 1.1 + 0.5, bar.get_y() + bar.get_height()/2,
                f'{val:,}', va='center', fontweight='bold', fontsize=10)

fig.tight_layout()
plt.show()
plt.close(fig)

png

4.2 최종 후보 변이 상세 정보

Prioritization으로 선별된 변이를 임상 보고서 형식으로 출력합니다.

python
# 최종 후보 변이 상세 정보
if len(prioritized) == 0:
    print('최종 후보 변이가 없습니다. 임계값을 완화해 재시도하겠습니다.')
    # P/LP 변이만 (star 조건 제외) 다시 사용
    prioritized = step4.copy()

print(f'=== 환자 {target_sample} - 최종 후보 변이 ({len(prioritized)}개) ===\n')

for i, row in prioritized.head(10).reset_index(drop=True).iterrows():
    print(f'[Variant {i+1}]')
    print(f'  위치        : {row["CHROM"]}:{row["POS"]} {row["REF"]}>{row["ALT"]}  ({row["rsID"]})')
    print(f'  유전자      : {row["GENE"]}')
    print(f'  Consequence : {row["CONSEQUENCE"]}')
    print(f'  CLNSIG      : {row["CLNSIG_group"]}  ({row["CLNSIG"]})')
    print(f'  Review      : {row["CLNREVSTAT"]}')
    print(f'  질환        : {str(row["CLNDN"])[:100]}')
    print(f'  Zygosity    : {row["ZYGOSITY"]}')
    print()
최종 후보 변이가 없습니다. 임계값을 완화해 재시도하겠습니다.
=== 환자 NA18939 - 최종 후보 변이 (0개) ===

4.3 질환 관련 유전자별 변이 분포

Pathogenic / Likely pathogenic 변이가 어느 유전자에 집중되는지 확인합니다.

python
# Pathogenic/Likely pathogenic 변이의 유전자 분포
plp_df = ann_df[ann_df['CLNSIG_group'].isin(['Pathogenic', 'Likely pathogenic'])]
gene_counts = plp_df['GENE'].value_counts().head(15)

fig, axes = plt.subplots(1, 2, figsize=(16, 6), dpi=100)

axes[0].barh(gene_counts.index[::-1], gene_counts.values[::-1],
             color='#c0392b', edgecolor='black', linewidth=0.5)
axes[0].set_title(f'P/LP 변이가 있는 Top 15 유전자 (chr22)', fontsize=14, fontweight='bold')
axes[0].set_xlabel('환자 변이 수')

# 전체 환자 변이 vs P/LP 변이 zygosity 비교
zyg_all = ann_df['ZYGOSITY'].value_counts()
zyg_plp = plp_df['ZYGOSITY'].value_counts()

x = np.arange(3)
width = 0.35
categories = ['het', 'hom_alt', 'hom_ref']
all_vals = [zyg_all.get(c, 0) for c in categories]
plp_vals = [zyg_plp.get(c, 0) for c in categories]

# 비율로 비교
all_pct = [v/sum(all_vals)*100 for v in all_vals]
plp_pct = [v/sum(plp_vals)*100 if sum(plp_vals) else 0 for v in plp_vals]

axes[1].bar(x - width/2, all_pct, width, label='전체 주석 변이', color='#3498db', edgecolor='black')
axes[1].bar(x + width/2, plp_pct, width, label='P/LP 변이', color='#c0392b', edgecolor='black')
axes[1].set_xticks(x)
axes[1].set_xticklabels(categories)
axes[1].set_ylabel('비율 (%)')
axes[1].set_title('Zygosity 분포 비교', fontsize=14, fontweight='bold')
axes[1].legend()

fig.tight_layout()
plt.show()
plt.close(fig)

png


5. 암(Somatic) · 희귀질환(Germline) 해석 차이 비교

지금까지 4장 까지는 Germline(NA18939) 환자의 희귀질환 해석 파이프라인을 수행했습니다. 이 장에서는 별도의 공공 데이터인 HCC1395 유방암 세포주의 somatic VCF 를 추가로 분석하여, 두 해석 전략이 실제로 어떻게 다른지 실 데이터로 비교합니다.

5.1 두 해석 전략의 근본적 차이

항목Germline (희귀질환)Somatic (암)
변이 기원생식세포 → 모든 세포에 존재체세포 → 종양 세포에만 존재
샘플 구성환자 단일 VCFTumor + Normal paired VCF
분석 대상Rare, 고침투 변이Somatic mutation (VAF 다양)
Allele Frequency0.5 (het) / 1.0 (hom)0.0 ~ 1.0 (종양 clone에 따라)
주요 DBClinVar, OMIM, gnomADCOSMIC, OncoKB, CIViC
분류 guidelineACMG PVS1, PS, PM, PP, BS, BPAMP/ASCO/CAP Tier I-IV
임상 질문"진단 확정이 가능한가?""치료 타깃 / 예후 마커는?"
원인 변이 수1~2개 (단일 유전자 질환)수십~수천 개 (동반 돌연변이)

5.2 Somatic 실습 데이터: HCC1395

본 실습의 Somatic 트랙에는 SEQC2 consortium 의 HCC1395 high-confidence somatic call set 을 사용합니다. HCC1395 는 유방암 연구에서 표준 벤치마크로 사용되는 세포주입니다.

항목내용
Cell lineHCC1395 (ATCC CRL-2324)
Matched normalHCC1395BL (EBV-transformed B-lymphoblast)
원 환자 정보43세 여성 (Caucasian), primary ductal carcinoma (TNBC)
VCF 출처NCBI SEQC2 Somatic_Mutation_WG release v1.2.1
ReferenceGRCh38
Callers다중 aligner(BWA/Bowtie/Novo) × 다중 caller 합의 (SomaticSeq)
검증HighConf / MedConf / LowConf 분류, PacBio long-read 검증 포함

⚠️ Reference genome 차이: Germline 데이터는 GRCh37, Somatic 데이터는 GRCh38 입니다. 실제 임상에서도 다른 연구 데이터가 섞여 올 때 자주 발생하는 이슈입니다. 본 실습은 두 트랙을 각자의 reference 에 맞는 ClinVar 로 독립 annotation 한 뒤, 해석 결과 단계에서 비교합니다.

python
# HCC1395 Somatic VCF 기본 통계
print('=== HCC1395 Somatic VCF 구조 ===')
# 헤더 요약
r = subprocess.run(['bcftools', 'view', '-h', SOMATIC_VCF], capture_output=True, text=True)
hdr = r.stdout.split('\n')
print(f'헤더 라인 수: {len(hdr)}')
print('\nReference:')
for l in hdr:
    if l.startswith('##reference'):
        print(f'  {l}')
        break

# 주요 INFO 필드 (somatic 특이적)
print('\n주요 INFO 필드:')
for l in hdr:
    if l.startswith('##INFO'):
        for key in ['TVAF', 'NVAF', 'bwaClassification', 'nPASSES', 'SOMATIC']:
            if f'ID={key},' in l:
                desc = re.search(r'Description="([^"]+)"', l)
                print(f'  [{key:20s}] {desc.group(1) if desc else ""}')

# 변이 수
r = subprocess.run(['bcftools', 'view', '-H', SOMATIC_VCF], capture_output=True, text=True)
lines = [l for l in r.stdout.strip().split('\n') if l]
n_total = len(lines)

# SNV / INDEL 분리 카운트
n_snv = sum(1 for l in lines if len(l.split('\t')[3]) == 1 and len(l.split('\t')[4]) == 1)
n_indel = n_total - n_snv

# Confidence 레벨
n_highconf = sum(1 for l in lines if 'HighConf' in l.split('\t')[6])
n_medconf  = sum(1 for l in lines if 'MedConf'  in l.split('\t')[6])

print(f'\n=== HCC1395 chr22 변이 통계 ===')
print(f'  총 변이 수     : {n_total:,}')
print(f'  SNV           : {n_snv:,}')
print(f'  INDEL         : {n_indel:,}')
print(f'  HighConf 변이 : {n_highconf:,}')
print(f'  MedConf 변이  : {n_medconf:,}')
=== HCC1395 Somatic VCF 구조 ===
헤더 라인 수: 96

Reference:
  ##reference=file:/SEQC2_Resources/GRCh38.d1.vd1.fa

주요 INFO 필드:
  [TVAF                ] tumor VAF combining 3 aligners
  [NVAF                ] normal VAF combining 3 aligners
  [nPASSES             ] number of samples where the variant is classified as PASS by SomaticSeq
  [bwaClassification   ] bwa-centric classification: Strong, Weak, Neutral, or Likely False Positive based on bwa-aligned data sets
  [SOMATIC             ] Denotes somatic mutation

=== HCC1395 chr22 변이 통계 ===
  총 변이 수     : 657
  SNV           : 627
  INDEL         : 30
  HighConf 변이 : 616
  MedConf 변이  : 41

5.3 Tumor VAF (TVAF) 분포 — Somatic 특이 지표

VAF (Variant Allele Frequency) 는 somatic 변이 해석의 핵심 지표입니다.

  • VAF ≈ 0.5 → 초기 driver mutation 또는 germline 오염 의심
  • VAF ≈ 1.0 → Loss of heterozygosity (LOH), tumor purity 100%
  • VAF 낮음 (< 0.1) → Subclonal mutation, 종양 내 이질성 (heterogeneity)
  • VAF ~ NVAF → 실제로는 germline 변이일 가능성

Germline 변이는 0/0.5/1.0 세 값 근처에 몰리는 반면, Somatic 변이는 연속 분포를 이룹니다. 이것이 두 변이 유형의 가장 큰 차이입니다.

python
# Somatic VCF 파싱 - TVAF, NVAF, FILTER 수집
somatic_records = []

vcf = VCF(SOMATIC_VCF)
for v in vcf:
    tvaf = v.INFO.get('TVAF')
    nvaf = v.INFO.get('NVAF')
    bwa_class = v.INFO.get('bwaClassification')
    n_passes = v.INFO.get('nPASSES')
    flt = v.FILTER if v.FILTER else 'PASS'

    var_type = 'SNV' if (len(v.REF) == 1 and len(v.ALT[0]) == 1) else 'INDEL'

    somatic_records.append({
        'CHROM': v.CHROM,
        'POS': v.POS,
        'REF': v.REF,
        'ALT': v.ALT[0] if v.ALT else '.',
        'FILTER': flt,
        'VAR_TYPE': var_type,
        'TVAF': tvaf,
        'NVAF': nvaf,
        'nPASSES': n_passes,
        'bwaClassification': bwa_class,
    })
vcf.close()

somatic_df = pd.DataFrame(somatic_records)
print(f'Somatic 변이: {len(somatic_df):,}')
print(f'\n=== TVAF / NVAF 기초 통계 ===')
print(somatic_df[['TVAF', 'NVAF']].describe().round(3))

# TVAF 분포 시각화
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

# 1. TVAF 히스토그램
tvaf_valid = somatic_df['TVAF'].dropna()
axes[0].hist(tvaf_valid, bins=40, color='#e74c3c', edgecolor='black',
             linewidth=0.3, alpha=0.8)
axes[0].axvline(0.5, color='blue', linestyle='--', alpha=0.7,
                label='VAF=0.5 (heterozygous-like)')
axes[0].axvline(1.0, color='purple', linestyle='--', alpha=0.7,
                label='VAF=1.0 (LOH)')
axes[0].set_title('Tumor VAF 분포 (HCC1395)', fontsize=13, fontweight='bold')
axes[0].set_xlabel('Tumor VAF')
axes[0].set_ylabel('변이 수')
axes[0].legend(fontsize=9)

# 2. NVAF vs TVAF scatter
axes[1].scatter(somatic_df['NVAF'], somatic_df['TVAF'],
                s=10, alpha=0.5, c='#c0392b')
axes[1].plot([0, 1], [0, 1], 'k--', alpha=0.3, label='y=x (germline 의심선)')
axes[1].axhline(0.05, color='gray', linestyle=':', alpha=0.5)
axes[1].set_xlabel('Normal VAF')
axes[1].set_ylabel('Tumor VAF')
axes[1].set_title('NVAF vs TVAF — Somatic 변이는 좌상단(TVAF 높고 NVAF 낮음)', fontsize=12, fontweight='bold')
axes[1].set_xlim(-0.02, 0.5)
axes[1].legend(fontsize=9)

# 3. VAF 구간별 분류 (Clonal / Subclonal)
vaf_bins = {
    'Subclonal\n(TVAF<0.1)': (somatic_df['TVAF'] < 0.1).sum(),
    'Minor\n(0.1-0.3)': ((somatic_df['TVAF'] >= 0.1) & (somatic_df['TVAF'] < 0.3)).sum(),
    'Clonal\n(0.3-0.7)': ((somatic_df['TVAF'] >= 0.3) & (somatic_df['TVAF'] < 0.7)).sum(),
    'LOH-like\n(TVAF≥0.7)': (somatic_df['TVAF'] >= 0.7).sum(),
}
names = list(vaf_bins.keys())
vals  = list(vaf_bins.values())
colors = ['#3498db', '#f39c12', '#e74c3c', '#9b59b6']
bars = axes[2].bar(names, vals, color=colors, edgecolor='black', linewidth=0.5)
axes[2].set_title('VAF 구간별 변이 분류', fontsize=13, fontweight='bold')
axes[2].set_ylabel('변이 수')
for bar, v in zip(bars, vals):
    axes[2].text(bar.get_x() + bar.get_width()/2, bar.get_height() + max(vals)*0.01,
                 f'{v}\n({v/len(somatic_df)*100:.1f}%)',
                 ha='center', fontsize=9, fontweight='bold')

plt.tight_layout()
plt.show()
Somatic 변이: 657

=== TVAF / NVAF 기초 통계 ===
          TVAF     NVAF
count  657.000  657.000
mean     0.336    0.002
std      0.239    0.002
min      0.032    0.000
25%      0.120    0.000
50%      0.259    0.001
75%      0.494    0.002
max      0.999    0.015

png

5.4 Somatic 변이에 ClinVar GRCh38 annotation

Germline 환자와 동일한 파이프라인이지만, GRCh38 버전 ClinVar 를 사용하고 somatic prioritization 관점으로 해석합니다.

  • Germline: Pathogenic 이 중요 → 진단 확정
  • Somatic: Oncogene/TSG 에 위치한 기능적 변이 가 중요 → 표적 치료 탐색
python
# Somatic VCF 에 ClinVar GRCh38 annotation
somatic_annotated = f'{WORK_DIR}/HCC1395_somatic.clinvar.chr22.vcf.gz'

cmd = [
    'bcftools', 'annotate',
    '-a', CLINVAR_GRCH38,
    '-c', 'INFO/CLNSIG,INFO/CLNDN,INFO/CLNREVSTAT,INFO/GENEINFO,INFO/MC,INFO/ORIGIN',
    '-Oz', '-o', somatic_annotated,
    SOMATIC_VCF
]
r = subprocess.run(cmd, capture_output=True, text=True)
subprocess.run(['tabix', '-p', 'vcf', '-f', somatic_annotated],
               capture_output=True, text=True)
print(f'Somatic ClinVar annotation 완료: rc={r.returncode}')

# 주석된 somatic 변이 파싱
s_records = []
vcf = VCF(somatic_annotated)
for v in vcf:
    clnsig = v.INFO.get('CLNSIG')
    gene = v.INFO.get('GENEINFO')
    gene_sym = gene.split(':')[0] if gene else None
    mc = v.INFO.get('MC')
    consequence = None
    if mc:
        for token in mc.split(','):
            if '|' in token:
                consequence = token.split('|')[1]
                break
    s_records.append({
        'CHROM': v.CHROM,
        'POS': v.POS,
        'REF': v.REF,
        'ALT': v.ALT[0] if v.ALT else '.',
        'TVAF': v.INFO.get('TVAF'),
        'NVAF': v.INFO.get('NVAF'),
        'FILTER': v.FILTER if v.FILTER else 'PASS',
        'GENE': gene_sym,
        'CLNSIG': clnsig,
        'CLNDN': v.INFO.get('CLNDN'),
        'CONSEQUENCE': consequence,
    })
vcf.close()

som_ann_df = pd.DataFrame(s_records)

# ClinVar 주석 유무
n_in_clinvar = som_ann_df['CLNSIG'].notna().sum()
print(f'\nHCC1395 chr22 {len(som_ann_df):,}개 변이 중 ClinVar 등록 변이: {n_in_clinvar:,}')
print('(Somatic 변이는 ClinVar 보다 COSMIC/OncoKB 에 많이 등록되어 있음 - 교육상 ClinVar 만 사용)')
print('\n=== ClinVar 에 등록된 Somatic 변이 ===')
print(som_ann_df[som_ann_df['CLNSIG'].notna()].to_string(index=False))
Somatic ClinVar annotation 완료: rc=0

HCC1395 chr22 657개 변이 중 ClinVar 등록 변이: 2
(Somatic 변이는 ClinVar 보다 COSMIC/OncoKB 에 많이 등록되어 있음 - 교육상 ClinVar 만 사용)

=== ClinVar 에 등록된 Somatic 변이 ===
CHROM      POS REF ALT  TVAF  NVAF        FILTER    GENE                 CLNSIG                                                 CLNDN        CONSEQUENCE
   22 29141977   G   A 0.492 0.001 PASS;HighConf KREMEN1                 Benign not_provided|Ectodermal_dysplasia_13,_hair/tooth_type synonymous_variant
   22 46257699   G   A 0.265 0.002 PASS;HighConf  PKDREJ Uncertain_significance                                         not_specified   missense_variant

5.5 Somatic Prioritization — 암 driver 유전자 기반

Somatic 해석은 암 driver gene 에 위치한 기능적 변이를 찾는 것이 핵심입니다. chr22 에 위치한 대표적인 oncogene/TSG 중심으로 prioritization 을 수행합니다.

AMP/ASCO/CAP Tier 기반 분류 (간이 적용)

  • Tier I: FDA 승인 치료제 타깃 (e.g. BRAF V600E)
  • Tier II: 가이드라인/임상시험 근거
  • Tier III: VUS — 임상적 의미 불명
  • Tier IV: Benign / Likely benign
python
# chr22 에 위치한 대표적 암 유전자 (COSMIC Cancer Gene Census 기반, chr22 일부)
cancer_genes_chr22 = [
    'NF2',      # Neurofibromatosis type 2 (tumor suppressor)
    'CHEK2',    # DNA damage response (tumor suppressor)
    'EWSR1',    # Ewing sarcoma fusion partner
    'MAPK1',    # ERK2 signaling (oncogene)
    'PDGFB',    # Platelet-derived growth factor (oncogene)
    'BCR',      # BCR-ABL fusion (CML)
    'SMARCB1',  # SWI/SNF complex (tumor suppressor)
    'MN1',      # Meningioma-related
    'EP300',    # Histone acetyltransferase (tumor suppressor)
    'NF2', 'SF3B1', 'TCF7L2',
]

# HCC1395 변이 중 암 유전자에 위치한 것 필터링 (GENE 주석은 ClinVar 로부터 온 것이므로 주변 확장 검색 시도)
# VEP REST 를 써서 모든 HCC1395 변이에 gene symbol 부착 (chr22 657개 - 5변이씩 배치)
import json as _json
import requests

VEP_URL_38 = 'https://rest.ensembl.org/vep/human/region'

def annotate_with_vep_rest(df, chunk_size=200):
    """VEP REST (GRCh38) 로 gene symbol + consequence 가져오기"""
    headers = {'Content-Type': 'application/json', 'Accept': 'application/json'}
    results = []
    for i in range(0, len(df), chunk_size):
        chunk = df.iloc[i:i + chunk_size]
        payload = {'variants': [
            f'{r.CHROM} {r.POS} . {r.REF} {r.ALT} . . .'
            for r in chunk.itertuples()
        ]}
        try:
            r = requests.post(VEP_URL_38, headers=headers,
                              data=_json.dumps(payload), timeout=60)
            if r.status_code == 200:
                results.extend(r.json())
            else:
                print(f'  chunk {i}: HTTP {r.status_code}')
        except Exception as e:
            print(f'  chunk {i} 오류: {e}')
    return results

# HighConf 변이만 대상으로 VEP annotation (657개 → 약 3 chunks)
highconf_df = som_ann_df[som_ann_df['FILTER'].apply(
    lambda f: 'HighConf' in (f if isinstance(f, str) else ','.join(f) if f else ''))].copy()
# cyvcf2 FILTER 는 문자열로 반환되므로 간단히 재검사
highconf_df = som_ann_df[som_ann_df['FILTER'].astype(str).str.contains('HighConf', na=False)].copy()
print(f'HighConf 변이: {len(highconf_df)}/{len(som_ann_df)} — VEP REST annotation 수행')

vep_res = annotate_with_vep_rest(highconf_df)
print(f'VEP 응답 수: {len(vep_res)}')

# gene_symbol / consequence 추출
def pick_best(tc_list):
    if not tc_list:
        return {}
    canonical = [t for t in tc_list if t.get('canonical') == 1]
    return (canonical[0] if canonical else tc_list[0])

vep_lookup = {}
for item in vep_res:
    best = pick_best(item.get('transcript_consequences', []))
    vep_lookup[(str(item.get('seq_region_name')), int(item.get('start')))] = {
        'gene_symbol': best.get('gene_symbol'),
        'impact': best.get('impact'),
        'most_severe': item.get('most_severe_consequence'),
    }

# highconf_df 에 VEP 결과 merge
highconf_df['vep_gene']    = highconf_df.apply(
    lambda r: vep_lookup.get((str(r['CHROM']), r['POS']), {}).get('gene_symbol'), axis=1)
highconf_df['vep_impact']  = highconf_df.apply(
    lambda r: vep_lookup.get((str(r['CHROM']), r['POS']), {}).get('impact'), axis=1)
highconf_df['vep_severe']  = highconf_df.apply(
    lambda r: vep_lookup.get((str(r['CHROM']), r['POS']), {}).get('most_severe'), axis=1)

# 암 유전자 필터링
cancer_hits = highconf_df[highconf_df['vep_gene'].isin(set(cancer_genes_chr22))]
print(f'\n=== HCC1395 의 암 유전자 변이 (chr22, HighConf) ===')
print(f'암 유전자 후보: {len(cancer_hits)}')
if len(cancer_hits):
    cols = ['CHROM', 'POS', 'REF', 'ALT', 'TVAF', 'NVAF',
            'vep_gene', 'vep_severe', 'vep_impact']
    print(cancer_hits[cols].to_string(index=False))
else:
    print('(해당 없음 — chr22 는 암 유전자 hotspot 이 상대적으로 적음)')

# 기능 영향 기반 순위 (HIGH/MODERATE 만)
functional_hits = highconf_df[highconf_df['vep_impact'].isin(['HIGH', 'MODERATE'])].copy()
functional_hits = functional_hits.sort_values('TVAF', ascending=False)
print(f'\n=== HighConf + Coding impact (HIGH/MODERATE) 변이 — 상위 10 ===')
cols = ['CHROM', 'POS', 'REF', 'ALT', 'TVAF', 'vep_gene',
        'vep_severe', 'vep_impact']
print(functional_hits[cols].head(10).to_string(index=False))
HighConf 변이: 616/657 — VEP REST annotation 수행
VEP 응답 수: 616

=== HCC1395 의 암 유전자 변이 (chr22, HighConf) ===
암 유전자 후보: 12
CHROM      POS REF ALT  TVAF  NVAF vep_gene                         vep_severe vep_impact
   22 23178829   C   G 0.299 0.002      BCR              upstream_gene_variant   MODIFIER
   22 23237703   G   A 0.108 0.000      BCR                     intron_variant   MODIFIER
   22 23274642   G   A 0.113 0.000      BCR                     intron_variant   MODIFIER
   22 23802508   C   G 0.181 0.001  SMARCB1 non_coding_transcript_exon_variant   MODIFIER
   22 27761963   G   A 0.320 0.002      MN1                     intron_variant   MODIFIER
   22 27778986   T   A 0.087 0.000      MN1                     intron_variant   MODIFIER
   22 28688787   G   C 0.465 0.001    CHEK2                     intron_variant   MODIFIER
   22 28731132   G   A 0.073 0.000    CHEK2                     intron_variant   MODIFIER
   22 29286163   G   A 0.529 0.000    EWSR1                     intron_variant   MODIFIER
   22 39236803   G   A 0.219 0.000    PDGFB                     intron_variant   MODIFIER
   22 41135122   G   A 0.135 0.001    EP300                     intron_variant   MODIFIER
   22 41181018   G   T 0.252 0.000    EP300                     intron_variant   MODIFIER

=== HighConf + Coding impact (HIGH/MODERATE) 변이 — 상위 10 ===
CHROM      POS REF ALT  TVAF vep_gene       vep_severe vep_impact
   22 50177152   C   T 0.998    PANX2 missense_variant   MODERATE
   22 37631021   C   G 0.494     GGA1 missense_variant   MODERATE
   22 37570268   C   G 0.490   LGALS2 missense_variant   MODERATE
   22 46257699   G   A 0.265   PKDREJ missense_variant   MODERATE
   22 41524891   G   C 0.240     ACO2 missense_variant   MODERATE
   22 37374978   G   A 0.161    ELFN2 missense_variant   MODERATE
   22 50210823   G   C 0.089  SELENOO missense_variant   MODERATE
   22 35726447   G   T 0.047    APOL5 missense_variant   MODERATE

5.6 Germline(NA18939) vs Somatic(HCC1395) 해석 결과 비교

두 트랙의 chr22 해석 결과를 한 눈에 비교합니다.

python
# Germline(NA18939) vs Somatic(HCC1395) 비교 시각화
fig, axes = plt.subplots(2, 2, figsize=(16, 10), dpi=100)

# 1. 전체 변이 수
germline_counts = {
    '전체 변이': n_patient,
    'ClinVar 주석': len(ann_df),
    'P/LP': len(plp_df),
}
somatic_counts = {
    '전체 변이': len(som_ann_df),
    'ClinVar 주석': n_in_clinvar,
    'Coding impact\n(HIGH/MOD)': len(functional_hits),
}

x = np.arange(3)
width = 0.35
axes[0, 0].bar(x - width/2, list(germline_counts.values()), width,
                label='Germline (NA18939)', color='#3498db', edgecolor='black')
axes[0, 0].bar(x + width/2, list(somatic_counts.values()), width,
                label='Somatic (HCC1395)', color='#e74c3c', edgecolor='black')
axes[0, 0].set_xticks(x)
axes[0, 0].set_xticklabels(['전체', 'ClinVar\n주석', 'Filter 후\n후보'])
axes[0, 0].set_ylabel('변이 수 (log)')
axes[0, 0].set_yscale('log')
axes[0, 0].set_title('Germline vs Somatic — 변이 수 비교 (chr22)',
                      fontsize=13, fontweight='bold')
axes[0, 0].legend()
for i, (g, s) in enumerate(zip(germline_counts.values(), somatic_counts.values())):
    if g > 0:
        axes[0, 0].text(i - width/2, g, f'{g:,}', ha='center', va='bottom', fontsize=9)
    if s > 0:
        axes[0, 0].text(i + width/2, s, f'{s:,}', ha='center', va='bottom', fontsize=9)

# 2. Allele Frequency 분포 비교 (Germline 0/0.5/1.0 vs Somatic 연속)
germline_af = ann_df['ZYGOSITY'].map({'hom_ref': 0.0, 'het': 0.5, 'hom_alt': 1.0}).dropna()
somatic_af_valid = somatic_df['TVAF'].dropna()
# 수치 이상치 방어: [0, 1] 범위만 사용
somatic_af_valid = somatic_af_valid[(somatic_af_valid >= 0) & (somatic_af_valid <= 1)]

bins = np.linspace(0, 1, 41)
if len(germline_af) > 0:
    axes[0, 1].hist(germline_af, bins=bins, alpha=0.6,
                     label='Germline GT (NA18939)', color='#3498db', edgecolor='black')
if len(somatic_af_valid) > 0:
    axes[0, 1].hist(somatic_af_valid, bins=bins, alpha=0.6,
                     label='Somatic TVAF (HCC1395)', color='#e74c3c', edgecolor='black')
axes[0, 1].set_title('Allele Frequency 분포', fontsize=13, fontweight='bold')
axes[0, 1].set_xlabel('Allele Frequency')
axes[0, 1].set_ylabel('변이 수 (log)')
axes[0, 1].set_yscale('log')
axes[0, 1].set_xlim(0, 1)
axes[0, 1].legend()

# 3. Consequence 분포 비교 (상위 6개)
germline_cons = ann_df['CONSEQUENCE'].dropna().value_counts().head(6)
somatic_cons  = pd.Series([v['most_severe'] for v in vep_lookup.values()
                            if v.get('most_severe')]).value_counts().head(6)

# union 크기 방어: 최대 12개로 제한
all_cons = sorted(set(germline_cons.index) | set(somatic_cons.index))[:12]
g_vals = [germline_cons.get(c, 0) for c in all_cons]
s_vals = [somatic_cons.get(c, 0) for c in all_cons]

y = np.arange(len(all_cons))
height = 0.35
if len(all_cons) > 0:
    axes[1, 0].barh(y - height/2, g_vals, height,
                     label='Germline', color='#3498db', edgecolor='black')
    axes[1, 0].barh(y + height/2, s_vals, height,
                     label='Somatic', color='#e74c3c', edgecolor='black')
    axes[1, 0].set_yticks(y)
    axes[1, 0].set_yticklabels(all_cons, fontsize=9)
axes[1, 0].set_xlabel('변이 수')
axes[1, 0].set_title('Consequence 분포 (상위)', fontsize=13, fontweight='bold')
axes[1, 0].legend()

# 4. 해석의 임상적 초점 요약 (axis 좌표계 사용)
axes[1, 1].axis('off')
axes[1, 1].set_xlim(0, 1)
axes[1, 1].set_ylim(0, 1)
focus_text = (
    "Germline (NA18939)\n"
    "───────────────\n"
    "• 희귀질환 진단 여부\n"
    "• ACMG 분류 (P / LP / VUS / B)\n"
    "• 가족 검사 및 유전상담\n"
    "• DB: ClinVar / OMIM / gnomAD\n\n"
    "Somatic (HCC1395)\n"
    "───────────────\n"
    "• 표적치료제 탐색\n"
    "• AMP Tier I-IV 분류\n"
    "• Tumor clonality (VAF 분포)\n"
    "• DB: COSMIC / OncoKB / CIViC\n"
)
# family='monospace' 제거 → 한글 깨짐 방지 (sans-serif 한글 폰트 사용)
axes[1, 1].text(0.05, 0.5, focus_text, fontsize=11,
                verticalalignment='center',
                transform=axes[1, 1].transAxes)
axes[1, 1].set_title('해석의 임상적 초점', fontsize=13, fontweight='bold')

fig.tight_layout()
plt.show()
plt.close(fig)

png

5.7 실제 임상 적용 시 체크리스트

단계Germline 해석Somatic 해석
1. 샘플환자 단독 WGS/WESTumor + Matched Normal
2. Variant callingGATK HaplotypeCaller, DeepVariantMutect2, Strelka2, VarScan2, SomaticSeq
3. FilteringHardy-Weinberg, trio 분석tumor VAF, Panel of Normal
4. AnnotationVEP + ClinVar + gnomADVEP + COSMIC + OncoKB
5. PrioritizationACMG guideline (P/LP/VUS/LB/B)AMP/ASCO/CAP Tier I-IV
6. 보고서진단 확정 + 유전상담표적치료제 + 임상시험 매칭

6. 결과물 저장 및 요약

python
# Germline prioritized variants 저장
out_path = f'{WORK_DIR}/patient_{target_sample}.prioritized.tsv'
prioritized.to_csv(out_path, sep='\t', index=False)
print(f'[Germline] 우선순위 변이 저장: {out_path}')
print(f'  - 샘플: {target_sample}')
print(f'  - 변이 수: {len(prioritized)}')

# Germline P/LP 변이 저장
germline_out = f'{WORK_DIR}/patient_{target_sample}.germline_plp.tsv'
plp_df.to_csv(germline_out, sep='\t', index=False)
print(f'\n[Germline] P/LP 변이 저장: {germline_out}')
print(f'  - 변이 수: {len(plp_df)}')

# Somatic HighConf + Coding 변이 저장
somatic_out = f'{WORK_DIR}/HCC1395_somatic.prioritized.tsv'
functional_hits.to_csv(somatic_out, sep='\t', index=False)
print(f'\n[Somatic] Functional 변이 저장: {somatic_out}')
print(f'  - 샘플: HCC1395')
print(f'  - 변이 수: {len(functional_hits)}')
print(f'  - 암 유전자 위치 변이: {len(cancer_hits)}')
[Germline] 우선순위 변이 저장: /tier4/DSC/jheepark/bbp-wgs-data/individual/patient_NA18939.prioritized.tsv
  - 샘플: NA18939
  - 변이 수: 0

[Germline] P/LP 변이 저장: /tier4/DSC/jheepark/bbp-wgs-data/individual/patient_NA18939.germline_plp.tsv
  - 변이 수: 0

[Somatic] Functional 변이 저장: /tier4/DSC/jheepark/bbp-wgs-data/individual/HCC1395_somatic.prioritized.tsv
  - 샘플: HCC1395
  - 변이 수: 8
  - 암 유전자 위치 변이: 12

이번 강의 요약

항목Germline (NA18939)Somatic (HCC1395)
데이터 출처1000 Genomes Phase 3SEQC2 HCC1395 high-confidence callset
ReferenceGRCh37GRCh38
변이 수 (chr22)~수만개657개 (SNV 627 + INDEL 30)
주요 INFOAF, AC (population freq)TVAF, NVAF, HighConf, PACB 검증
AnnotationClinVar GRCh37 + VEPClinVar GRCh38 + VEP REST
PrioritizationClinVar → Coding → P/LP → ≥ 1 starHighConf → Coding (HIGH/MOD) → 암 유전자
임상 기준ACMG guidelineAMP/ASCO/CAP Tier

핵심 포인트

  1. Annotation 은 VCF 해석의 시작점 — 위치/염기만으로는 임상적 의미를 알 수 없음
  2. ClinVar CLNSIG + CLNREVSTAT 조합으로 신뢰도 높은 병원성 변이 선별
  3. VEP 는 기능적 영향, ClinVar 는 임상적 병원성 — 상호 보완적이므로 두 결과를 함께 검토
  4. Prioritization 은 funnel 구조 — 빈도 → 기능 → 임상 DB → 유전자 순으로 좁혀감
  5. Germline vs Somatic 은 완전히 다른 파이프라인
    • Germline: 이산적 VAF (0/0.5/1.0), 전체 세포 공유, ACMG guideline
    • Somatic: 연속 VAF 분포 (subclone), 종양 내 heterogeneity, AMP Tier
  6. Reference genome 버전 차이는 실제 임상에서 자주 발생하는 이슈 — bcftools annotate 는 position/REF/ALT 매칭이므로 reference 가 반드시 일치해야 함

주요 명령어 정리

bash
# ClinVar INFO 주입 (Germline)
bcftools annotate \
    -a clinvar.vcf.gz \
    -c INFO/CLNSIG,INFO/CLNDN,INFO/GENEINFO \
    -Oz -o patient.clinvar.vcf.gz \
    patient.vcf.gz

# ClinVar INFO 주입 (Somatic, GRCh38)
bcftools annotate \
    -a clinvar_GRCh38.vcf.gz \
    -c INFO/CLNSIG,INFO/CLNDN,INFO/GENEINFO,INFO/MC,INFO/ORIGIN \
    -Oz -o tumor.clinvar.vcf.gz \
    tumor.vcf.gz

# TVAF 필터링 (Somatic, clonal 만)
bcftools view -i 'INFO/TVAF>=0.3 && INFO/TVAF<=0.7' tumor.clinvar.vcf.gz

# 병원성 변이만 추출 (Germline)
bcftools view -i 'INFO/CLNSIG~"Pathogenic"' patient.clinvar.vcf.gz
python
# VEP REST API (GRCh37 vs GRCh38)
import requests, json
url_37 = 'https://grch37.rest.ensembl.org/vep/human/region'
url_38 = 'https://rest.ensembl.org/vep/human/region'
r = requests.post(url_38,
                  headers={'Content-Type':'application/json', 'Accept':'application/json'},
                  data=json.dumps({'variants':['22 16258221 . C T . . .']}))
print(r.json()[0]['most_severe_consequence'])

다음 강의 예고

4강: 환자 코호트 기반 질병 연관성 분석

  • 다수 환자 VCF 통합 및 코호트 QC 수행
  • GWAS 분석 실행 및 결과 시각화
  • PRS(Polygenic Risk Score) 계산을 통한 개인별 유전적 위험도 비교