발현 클러스터링 — 상관관계와 정렬로 유전자 co-expression 그룹 찾기
이 토픽을 마치면
교과서에서 배운 numpy, 상관관계, 정렬 을 엮어서, 여러 샘플에 걸친 유전자 발현 데이터에서 함께 움직이는(co-expression) 그룹을 찾는 계층적 클러스터링을 직접 구현할 수 있습니다. scikit-learn이나 scipy.cluster가 뒤에서 하는 일의 원리를 코드로 이해합니다.
이 글은 교육용 일반 예제 입니다. 실전 클러스터링은 pandas/scanpy/scikit-learn을 씁니다. 여기서는 이 도구들의 내부 개념을 정확히 다룹니다.
"이 5,000개 유전자 중 어떤 게 함께 움직이지?" — 눈으로는 못 본다
여러분에게 RNA-seq 데이터가 있습니다. 5,000 유전자 × 30 샘플의 발현 행렬. 다음 질문을 던집니다.
- 어떤 유전자들이 함께 켜지고 꺼지는가?
- 이 co-expression 그룹이 어떤 생물학적 기능에 대응하는가?
5,000개 유전자의 30차원 벡터를 눈으로 비교하기는 불가능합니다. 자동으로 관계를 찾아 정렬해야 합니다.
순진한 접근: 유전자별로 발현 프로필을 그린다. 5,000개 그래프. 어차피 관계는 안 보임.
진짜 접근: 두 축이 있습니다.
- 상관관계: 두 유전자의 발현 프로필이 얼마나 함께 움직이는지 정량. Pearson correlation.
- 계층적 클러스터링: 가장 유사한 쌍부터 그룹으로 병합. 결과가 dendrogram(계통수 유사).
이 둘을 조합하면 함께 움직이는 유전자 그룹들이 자연스러운 계층 구조로 나타납니다.
블랙박스에서 부품으로
부품 1: 발현 행렬과 상관관계
import numpy as np
def load_expression_matrix(path: str) -> tuple[np.ndarray, list[str], list[str]]: """ 반환: (matrix, gene_names, sample_names) matrix shape: (n_genes, n_samples) """ with open(path) as f: header = f.readline().strip().split("\t") sample_names = header[1:] gene_names = [] rows = [] for line in f: parts = line.strip().split("\t") gene_names.append(parts[0]) rows.append([float(x) for x in parts[1:]]) return np.array(rows), gene_names, sample_names
expr, genes, samples = load_expression_matrix("expression.tsv")print(f"유전자 {len(genes)}, 샘플 {len(samples)}")print(f"행렬 shape: {expr.shape}")이제 상관관계 행렬. 각 원소는 두 유전자 사이의 Pearson correlation.
def compute_correlation_matrix(expr: np.ndarray) -> np.ndarray: """ expr shape: (n_genes, n_samples) 반환: (n_genes, n_genes) 상관관계 행렬 """ return np.corrcoef(expr)
corr = compute_correlation_matrix(expr)print(f"corr shape: {corr.shape}") # (5000, 5000)print(f"대각선: {corr[0, 0]}") # 1.0 (자기 자신)np.corrcoef 는 각 행을 하나의 변수로 취급합니다. 즉 expr 이 (유전자, 샘플) 형태면 결과는 유전자-유전자 상관.
부품 2: 거리 행렬
클러스터링에서는 유사도(similarity) 대신 거리(distance) 를 씁니다. 상관관계는 -1~+1의 유사도라, 다음 변환으로 거리로 만듭니다.
def correlation_to_distance(corr: np.ndarray) -> np.ndarray: """ 거리 = 1 - correlation. 양의 상관 강함 → 거리 작음. 음의 상관 (반대로 움직임) → 거리 큼. """ return 1.0 - corr
dist = correlation_to_distance(corr)주의: 다른 정의도 있습니다. sqrt(2 * (1 - corr)) 는 유클리드 거리와 일관성 있는 정의. 목적에 따라 선택.
부품 3: 계층적 클러스터링 (Single Linkage)
Agglomerative 접근: 처음엔 각 유전자가 별개 클러스터. 거리가 가장 가까운 두 클러스터를 병합하는 것을 반복. 하나의 클러스터가 남을 때까지.
두 클러스터 사이의 거리를 정의해야 합니다. Single linkage 는 가장 단순한 정의: 두 클러스터의 원소 쌍 중 최소 거리.
def hierarchical_clustering_single(dist: np.ndarray) -> list[tuple[int, int, float]]: """ 반환: 병합 이벤트 리스트 [(cluster_a, cluster_b, merge_distance), ...] 각 병합 후 새 클러스터 id는 원래 개수 + 이벤트 인덱스로 부여. """ n = dist.shape[0] active_clusters = {i: [i] for i in range(n)} cluster_distances = {(i, j): dist[i, j] for i in range(n) for j in range(i + 1, n)} events: list[tuple[int, int, float]] = [] next_id = n while len(active_clusters) > 1: best_pair = min(cluster_distances, key=cluster_distances.get) i, j = best_pair merge_dist = cluster_distances[best_pair] events.append((i, j, merge_dist)) new_cluster = active_clusters[i] + active_clusters[j] del active_clusters[i] del active_clusters[j] active_clusters[next_id] = new_cluster # 새 클러스터와 나머지 클러스터의 거리 계산 (single linkage = min) new_distances = {} for existing_id in active_clusters: if existing_id == next_id: continue existing = active_clusters[existing_id] min_d = min( dist[a, b] for a in new_cluster for b in existing ) new_distances[(min(existing_id, next_id), max(existing_id, next_id))] = min_d cluster_distances = { k: v for k, v in cluster_distances.items() if i not in k and j not in k } cluster_distances.update(new_distances) next_id += 1 return events시간 복잡도: 순진한 구현으로 O(n³). 5,000개 유전자면 감당하기 어렵고, 실전은 O(n² log n) 알고리즘을 씁니다. 이 튜토리얼은 개념 이해가 목적이므로 순진한 구현.
부품 4: 결과에서 클러스터 추출
병합 이벤트 리스트에서 특정 개수의 클러스터를 뽑습니다.
def cut_dendrogram(events: list[tuple[int, int, float]], n_leaves: int, num_clusters: int): parent = list(range(n_leaves + len(events))) def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x def union(x, y, new_id): rx, ry = find(x), find(y) parent[rx] = new_id parent[ry] = new_id # 마지막 (num_clusters - 1) 개의 병합만 무시하고 그 앞까지 union events_to_apply = events[:-num_clusters + 1] if num_clusters > 1 else events next_id = n_leaves for a, b, _ in events_to_apply: union(a, b, next_id) next_id += 1 clusters = {} for leaf in range(n_leaves): root = find(leaf) clusters.setdefault(root, []).append(leaf) return list(clusters.values())
cluster_lists = cut_dendrogram(events, n_leaves=len(genes), num_clusters=10)for i, members in enumerate(cluster_lists): print(f"클러스터 {i}: {len(members)} 유전자")Union-Find 자료구조로 O(α(n)) ≈ O(1) 병합·조회. 큰 규모에서도 유용합니다.
Sorting과 정렬된 heatmap
클러스터링의 시각적 성과는 재정렬된 heatmap에서 나타납니다. 각 클러스터의 유전자들이 인접한 행이 되도록 정렬합니다.
def cluster_order(cluster_lists: list[list[int]]) -> list[int]: """각 클러스터 안에서 원래 인덱스 순서를 유지하며, 클러스터별로 정렬.""" result = [] for members in cluster_lists: result.extend(sorted(members)) return result
new_order = cluster_order(cluster_lists)reordered_expr = expr[new_order]이 재정렬된 행렬을 heatmap으로 그리면 각 클러스터가 명확한 블록으로 보입니다.
import matplotlib.pyplot as plt
def plot_heatmap(matrix: np.ndarray, ax=None) -> None: if ax is None: _, ax = plt.subplots(figsize=(6, 8)) im = ax.imshow(matrix, cmap="RdBu_r", aspect="auto") plt.colorbar(im, ax=ax)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 8))plot_heatmap(expr, ax1)ax1.set_title("Original")plot_heatmap(reordered_expr, ax2)ax2.set_title("Clustered")plt.tight_layout()plt.show()이 재정렬 전/후 대비가 클러스터링의 힘을 눈으로 확인시키는 결정적 시각화입니다.
페이딩 — 여러분이 채워야 할 세 빈칸
빈칸 1: Complete linkage / Average linkage
Single linkage 대신 complete linkage (두 클러스터 원소 쌍 중 최대 거리) 또는 average linkage (평균 거리)를 구현.
def hierarchical_clustering_complete(dist: np.ndarray) -> list[tuple[int, int, float]]: # single linkage 코드와 대부분 동일, min → max 로 교체 # ... # 클러스터간 거리 계산 부분: # TODO: min(dist[a, b] for ...) 를 max(dist[a, b] for ...) 로 pass힌트: 함수 하나만 파라미터화. linkage: Callable[[list[float]], float] = min or max or (lambda xs: sum(xs) / len(xs)).
빈칸 2: 클러스터의 대표 유전자
각 클러스터에서 다른 멤버들과 평균 상관관계가 가장 높은 유전자 = 대표 유전자.
def find_hub_genes( corr: np.ndarray, clusters: list[list[int]], gene_names: list[str]) -> list[tuple[str, float]]: """각 클러스터의 대표 유전자와 그 평균 상관관계.""" hubs = [] for members in clusters: # TODO: 각 멤버에 대해 다른 멤버들과의 평균 상관관계 계산 # 가장 높은 값을 가진 유전자를 hub로 선정 pass return hubs힌트:
best_score = -1best_gene = Nonefor m in members: score = np.mean([corr[m, other] for other in members if other != m]) if score > best_score: best_score = score best_gene = gene_names[m]빈칸 3: functional annotation (외부 도구 연동)
클러스터의 유전자 리스트를 KEGG pathway나 GO term과 매핑. 여기선 CSV 파일에서 매핑을 로드.
def enrich_clusters( clusters: list[list[str]], gene_to_pathway_csv: str) -> dict: """ 각 클러스터에서 어떤 pathway가 가장 많이 나타나는지 상위 3개. """ # TODO: CSV 로드 (gene, pathway 컬럼) # 각 클러스터에서 pathway 등장 빈도 계산 # 각 클러스터의 상위 3 pathway 반환 pass힌트: from collections import Counter; counter = Counter(); for g in members: counter.update(gene_pathways.get(g, [])).
성찰 — 실전 co-expression 도구와의 차이
scipy.cluster.hierarchy: 실전은 이 모듈의 linkage 함수를 씁니다. C로 짜인 O(n² log n) 알고리즘. 여러분의 O(n³) 순진한 구현 대비 100배 이상 빠릅니다.
WGCNA: 유전자 co-expression 네트워크의 표준 도구. Signed adjacency matrix, soft thresholding, module preservation 등의 정교한 개념을 씁니다. 여러분의 도구는 이것의 최소 뼈대.
scanpy: 단일 세포 RNA-seq 표준 스택. 클러스터링은 대개 Leiden algorithm — graph-based approach로, 여러분이 다룬 계층적 클러스터링과는 다른 계열.
Batch effect: 실전 발현 데이터는 다른 실험 배치에서 온 데이터를 합쳐야 합니다. Batch effect가 진짜 co-expression 시그널을 오염시킵니다. Combat, Harmony 같은 도구가 이 문제 해결.
동적 트리 자르기: 여러분의 cut_dendrogram 은 클러스터 개수를 지정. 실전 WGCNA는 dendrogram 모양을 보고 동적으로 자르는 알고리즘을 씁니다 (Dynamic Tree Cut).
확장 프로젝트
1. scipy로 재구현: 여러분의 순진한 클러스터링을 scipy.cluster.hierarchy.linkage 로 대체해 성능 비교.
2. dendrogram 시각화: scipy.cluster.hierarchy.dendrogram 으로 트리 시각화. 여러분의 events 리스트에서 scipy 형식으로 변환.
3. GO term enrichment: gseapy 로 각 클러스터의 GO term enrichment를 실제 수행.
4. Streamlit 대시보드: 사용자가 클러스터 개수를 슬라이더로 조정하면 heatmap과 대표 유전자가 실시간 갱신.
이 편의 부품 지도
- [F] numpy: 발현 행렬, 상관관계 행렬 조작.
np.corrcoef실전 활용. - [F] 상관관계: Pearson correlation을 두 유전자 유사도의 정량 기준으로 사용.
- [F] 정렬: 클러스터 재정렬로 heatmap 블록화. Union-Find로 결과 추출.
- [W] matplotlib: heatmap 시각화 (완성 스크립트 제공).
[F] = 여러분이 직접 구현 / [W] = 완성 코드로 제공.