Post

CITE-seq integration 어떤 embedding method 쓸것인가 to clustering results

scRNA + ADT + TCR/BCR 멀티오믹 데이터에서 WNN·totalVI·GEX-only 중 무엇을 고를지, 6단계 진단으로 결정한 기록.


1. 문제 상황

데이터

항목 내용
샘플 59개 (51개는 GEX+ADT, 8개는 GEX only)
Assay scRNA-seq + CITE-seq(137-plex TotalSeq-C, isotype 7개 포함) + TCR/BCR (TCR/BCR 결과는 integration계산에서 제외)
셀 수 전체 약 359,000 / ADT 보유 51샘플 약 342,000
결측 8개 샘플은 GEX+TCR/BCR만 (ADT 없음). 한 환자데이터에 집중
기술적 변수 중간에 sequencing core 변경 (chemistry 동일, 처리 기관 상이)
Batch 보정 모든 방법에서 SampleID 기준

시도한 세 가지

  1. WNN + Harmony — GEX·ADT를 각각 SampleID로 Harmony 통합 후 WNN으로 결합. 51샘플 object를 reference로 두고 ADT 없는 8샘플을 query로 projection.
  2. totalVI — 1번과 동일한 순서, python/scvi-tools 기반.
  3. GEX-only Harmony — ADT를 아예 쓰지 않음.

증상

  • WNN UMAP이 cell type을 가장 잘 반영하는 것처럼 보였다 (Azimuth 라벨 기준 육안 판단).
  • 그런데 같은 그래프에서 나온 clustering이 cell type을 따라가지 않았다.
  • 클러스터들이 UMAP 상에서 낱알처럼 흩어져 있었다 — 공간적으로 뭉치지 않음.
  • 클러스터링 알고리즘을 MSC로 바꿔봐도 동일.

R1.WNN UMAP

R2.totalVI UMAP

R3.GEX only UMAP

시작하기 전에 잡고 간 두 가지 전제

전제 1 — 알고리즘을 바꿔도 입력 그래프가 같으면 같은 구조가 나온다. Leiden → Louvain → MSC를 돌려가며 비교하는 건 의미가 없다. 고칠 지점은 그래프 위쪽이다.

전제 2 — UMAP 외형은 증거가 아니다. 2D 투영이 예쁜 것과 embedding이 cell type을 잘 보존하는 것은 다른 문제다. “어느 그림이 나아 보이나”를 “어느 embedding의 NMI/ARI가 높나”로 바꿔야 한다.

또한 clustering과 visualization은 같은 공간에서 할 의무가 없다. 그래프는 A로, 그림은 B로 가는 조합이 정답일 수 있다.


2. 확인 항목

① 클러스터가 대체 무엇을 잡고 있는가

Cell type이 아니라면 무엇인지부터 특정한다. 클러스터 라벨과 각 공변량 사이의 normalized mutual information을 재서 가장 높은 것이 클러스터의 정체다.

1
2
3
4
5
6
7
8
9
10
library(aricode)
md <- NYU@meta.data
cl <- as.factor(md$harmony_wnn_clusters)

md$mt_bin  <- cut(md$percent.mt,  quantile(md$percent.mt,  0:10/10, na.rm = TRUE), include.lowest = TRUE)
md$cnt_bin <- cut(md$nCount_SCT,  quantile(md$nCount_SCT,  0:10/10, na.rm = TRUE), include.lowest = TRUE)

vars <- c("predicted.celltype.l2", "Seqcore", "SampleID", "PatientID",
          "Timepoint", "mt_bin", "cnt_bin")
sapply(vars, function(v) NMI(cl, as.factor(md[[v]])))

결과

변수 NMI
predicted.celltype.l2 0.233
SampleID 0.068
PatientID 0.058
Timepoint 0.017
Seqcore 0.002

해석

  • Seqcore 0.002 — sequencing core 변경 효과는 SampleID Harmony로 완전히 흡수됐다. 가장 걱정했던 변수인데 흔적이 없다.
  • SampleID 0.068, PatientID 0.058 — batch도 donor도 아니다. Harmony가 제 일을 했다. (순서가 SampleID > PatientID > Timepoint인 것은 nesting 구조상 자연스럽다.)
  • celltype 0.233 — cell type도 아니다. 잘 된 PBMC clustering이라면 l2 기준 0.55~0.75가 나온다.

cell type도 batch도 아닌 무언가를 잡고 있다. 첫 번째 가설(batch 오염)은 기각.

⚠️ global NMI는 “특정 클러스터 몇 개만 한 샘플 전용”인 경우를 놓친다. 같이 볼 것:

1
2
smax <- apply(prop.table(table(cl, md$SampleID), 1), 1, max)
sort(smax, decreasing = TRUE)[1:10]   # 0.5 넘으면 sample-specific 클러스터

② 정답 라벨 자체가 믿을 만한가

①의 분모가 되는 라벨이 뭉개져 있으면 진단 전체가 무효다. 반드시 먼저 걸러야 한다.

1
2
summary(NYU$predicted.celltype.l2.score)
table(NYU$predicted.celltype.l2 == "Doublet")

결과

1
2
3
4
5
   Min. 1st Qu.  Median    Mean  3rd Qu.    Max.
 0.1851  0.6849  0.8861  0.8207  0.9898  1.0000

 FALSE   TRUE
341835     36

해석

median 0.886, 3rd Qu 0.99, Doublet 36개/342,000. 라벨은 깨끗하다. → 두 번째 가설(라벨 품질) 기각. ①의 0.233은 진짜 신호다.

이후 모든 계산은 고신뢰 셀로 제한한다:

1
2
keep <- md$predicted.celltype.l2.score > 0.75 & md$predicted.celltype.l2 != "Doublet"
ct   <- droplevels(as.factor(md$predicted.celltype.l2[keep]))

③ 클러스터 크기 분포 — singleton 함정

purity나 crosstab을 보기 전에 반드시 확인한다.

1
2
3
4
5
6
7
8
9
10
11
sort(table(md$harmony_wnn_clusters), decreasing = TRUE)

tab1 <- prop.table(table(cl[keep], md$predicted.celltype.l1[keep]), 1)
tab2 <- prop.table(table(cl[keep], ct), 1)
data.frame(
  n      = as.vector(table(cl[keep])),          # ← n 없이는 읽을 수 없다
  pur_l1 = round(apply(tab1, 1, max), 2),
  top_l1 = colnames(tab1)[apply(tab1, 1, which.max)],
  pur_l2 = round(apply(tab2, 1, max), 2),
  top_l2 = colnames(tab2)[apply(tab2, 1, which.max)]
)[order(-as.vector(table(cl[keep]))), ]

결과 — 25개 클러스터 중

  • 실질 클러스터 8개 (purity 0.26 ~ 0.50)
  • 셀 1~2개짜리 16개 (purity 정확히 1.00)

crosstab에서 비율이 0.00 / 0.50 / 1.00만 나오면 그 클러스터는 셀이 1~2개다.

해석

FindClusters(algorithm = 4, resolution = 0.02) — Leiden이 거대 덩어리 몇 개와 singleton 무더기를 뱉은 것. UMAP에서 라벨 9~24가 중앙 한 점에 몰려 있던 이유이기도 하다 (라벨은 median 좌표에 찍히므로, 흩어진 클러스터나 미세 클러스터는 중앙으로 모인다).

purity를 l1과 l2 둘 다 봐야 하는 이유 27개 l2 라벨 기준 purity는 정상 클러스터도 깎는다. 순수한 B 섬이라도 B naive 45% / B memory 40% / B intermediate 15%로 갈리므로 max가 0.45다. l1 purity가 0.85+이면 lineage는 잡힌 것이고, 필요한 건 그래프 재구축이 아니라 lineage별 2단계 subclustering이다.


④ Modality weight 분포 (WNN 전용)

WNN은 셀마다 두 modality의 가중치를 계산한다. 이 분포가 건강한지가 WNN 품질의 핵심 지표다.

1
2
3
4
5
6
summary(NYU_wADT$ADT.weight)

NYU_wADT$w_bin <- cut(NYU_wADT$ADT.weight, c(-Inf, .05, .95, Inf),
                      labels = c("RNA-only", "mixed", "ADT-only"))
table(NYU_wADT$w_bin)
FeaturePlot(NYU_wADT, "ADT.weight", reduction = "harmony.wnn.umap")

결과

1
2
3
4
5
     Min.   1st Qu.    Median      Mean    3rd Qu.      Max.
0.000e+00  1.51e-06  2.731e-01  4.462e-01  9.963e-01  1.000e+00

RNA-only    mixed  ADT-only
  145019    89357    107495      (42% / 26% / 31%)

해석

정상이라면 0.3~0.7에 몰린 단봉 분포여야 한다. 실제로는 1사분위가 0, 3사분위가 1인 U자형. 셀의 73%가 한쪽 modality만 보고 이웃을 정했다.

WNN 그래프가 두 개의 서로 다른 그래프를 셀 단위로 기워 붙인 모자이크가 됐다. 같은 cell type의 두 세포가 어느 modality가 이겼느냐에 따라 다른 동네에 배치된다.

원인 후보를 좁힌다:

1
2
cor(NYU_wADT$ADT.weight, log1p(NYU_wADT$nCount_ADT), method = "spearman")   # 0.20
round(prop.table(table(NYU_wADT$w_bin, NYU_wADT$SampleID), 2), 2)           # 샘플 간 균일
  • spearman 0.20 → ADT depth 탓이 아니다 (세 번째 가설 기각)
  • 샘플별 비율이 0.37~0.55로 균일 → staining batch 효과도 아니다 (네 번째 가설 기각)

배치 아티팩트가 아니라 이 데이터에서 WNN 계산이 체계적으로 만들어내는 결과다.


⑤ Weight가 cell type과 정렬되는가

1
round(prop.table(table(NYU$w_bin, NYU$predicted.celltype.l1), 2), 2)

결과

  B CD4 T CD8 T DC Mono NK other T
RNA-only 0.48 0.47 0.34 0.71 0.55 0.36 0.29
mixed 0.34 0.23 0.24 0.16 0.35 0.26 0.25
ADT-only 0.19 0.30 0.42 0.12 0.10 0.38 0.47

해석

Myeloid/DC는 RNA 쪽, T/NK는 ADT 쪽으로 기운다. 생물학적으로 타당하다 — 137-plex 패널은 CD4/CD8/CD45RA/CCR7로 T subset을 잘 가르지만 DC·mono subset은 transcriptome이 낫다. WNN이 설계 의도대로 작동하고는 있다.

문제는 어느 타입도 한쪽으로 쏠리지 않는다는 점이다. CD8 T 안에서 34%는 RNA로, 42%는 ADT로 이웃이 정해진다. 같은 타입인데 서로 다른 공간에서 이웃을 찾는다.

여기까지로는 “치명적”인지 “품질을 깎는 정도”인지 단정할 수 없다. 다음 단계가 필요하다.


⑥ 3-way 벤치마크 — 이것이 실제 답이다

같은 라벨, 같은 필터, 비슷한 클러스터 수로 세 방법을 나란히 잰다.

1
2
3
4
5
6
7
8
9
score <- function(cl) {
  c1 <- droplevels(as.factor(cl[keep]))
  c(k   = nlevels(c1),
    NMI = round(aricode::NMI(c1, ct), 3),
    ARI = round(aricode::ARI(c1, ct), 3))
}
rbind(WNN     = score(md$harmony_wnn_clusters),
      GEXonly = score(md$gex_harmony_clusters),
      totalVI = score(md$totalvi_clusters))

세 결과가 다른 object에 흩어져 있으면 barcode 기준으로 먼저 합친다:

1
2
3
4
md$cell   <- rownames(md)
md$gex_cl <- gex@meta.data[md$cell, "<컬럼명>"]
md$tvi_cl <- tvi@meta.data[md$cell, "<컬럼명>"]
keep <- keep & !is.na(md$gex_cl) & !is.na(md$tvi_cl)   # 세 방법 모두 있는 셀만

결과

방법 k NMI ARI
totalVI 16 0.688 0.493
GEX-only 15 0.528 0.386
WNN 20 0.269 0.143

해석 — 세 가지를 동시에 말한다

  1. WNN은 클러스터가 가장 많은데 점수가 가장 낮다. k=20으로 GEX-only(k=15)보다 많은데 NMI는 절반이다. → 클러스터 수 아티팩트가 아니라 진짜로 나쁘다. ③에서 유보했던 판단이 여기서 확정된다.

  2. WNN < GEX-only. ADT를 WNN 방식으로 넣으면 아예 안 넣느니만 못하다. 정보를 더한 게 아니라 파괴했다.

  3. totalVI > GEX-only (0.688 vs 0.528). 그런데 ADT에는 진짜 정보가 들어 있다. 제대로 모델링하면 이득이 나오는데 WNN이 그걸 못 쓴 것이다.

이 세 줄이 ④의 U자형 분포와 정확히 맞물린다. WNN은 CLR 값을 액면가로 받아 셀마다 modality를 양자택일하고, totalVI는 protein background를 negative binomial mixture로 명시적으로 모델링해 신호와 배경을 분리한다. 진단과 결과가 일치한다.

이 비교는 오히려 GEX-only에 유리하게 편향돼 있다. 정답 라벨이 SCT+spca 기반 GEX-only 매핑이기 때문이다. 그 핸디캡을 안고도 totalVI가 이겼다.


3. 결론

무엇을 선택했나

totalVI를 visualization·clustering 양쪽의 기본 embedding으로 채택.

판단 근거
totalVI가 1위 NMI 0.688 / ARI 0.493 (k=16)
WNN이 GEX-only보다도 낮음 0.269 vs 0.528 — ADT를 WNN으로 넣으면 역효과
원인 ADT.weight U자형 포화 (Q1=0, Q3=0.996), 셀의 73%가 단일 modality
원인이 batch가 아님 Seqcore NMI 0.002, SampleID 0.068
라벨 신뢰 가능 prediction score median 0.886, Doublet 36개

WNN 계열 파이프라인은 정리한다. DSB normalization으로 ADT를 고쳐 WNN을 살리는 길은 남아 있지만, 이미 이긴 방법이 있는 상황에서 들일 노력은 아니다.

후속 작업

  1. resolution sweep — k=16은 27개 l2 타입에 비해 부족하다. 클러스터 수가 라벨 수보다 적으면 NMI 상한이 눌린다. k≈30~35에서 NMI가 평평해지는 지점을 찾는다.
  2. 59샘플 joint totalVI — reference→query projection을 버린다. totalVI는 protein이 전혀 없는 batch를 mask로 native하게 처리하므로 한 번에 학습시키면 projection 아티팩트가 사라진다.
  3. ADT gating으로 독립 검증 — Azimuth 라벨은 GEX 기반이라 순환 논리 위험이 있다. CD3/CD4/CD8a/CD19/CD14/CD16/CD56 gating 라벨로 재확인.
  4. lineage별 2단계 subclustering — 27개 l2 타입을 global resolution 하나로 쪼개려는 것은 무리다. lineage를 먼저 나누고 각 lineage 안에서 재-embedding.

4. 재사용 체크리스트

새 멀티오믹 데이터에서 embedding을 고를 때 이 순서로 간다.

1
2
3
4
5
6
7
8
9
10
11
12
13
□ 0. 눈으로 고르지 않는다. UMAP 외형은 증거가 아니다.
□ 1. 정답 라벨의 신뢰도부터 확인 (prediction score, Doublet 비율)
      → 여기가 무너지면 이하 전부 무효
□ 2. 클러스터 크기 분포 확인 (singleton 걸러내기)
      → n 없는 purity 표는 읽을 수 없다
□ 3. 클러스터 vs 공변량 NMI (celltype / batch / donor / QC)
      → 클러스터의 정체를 특정
□ 4. (WNN인 경우) modality weight 분포가 단봉인지
      → U자형이면 그래프가 모자이크
□ 5. 후보 embedding들을 같은 라벨·비슷한 k로 NMI/ARI 비교
      → 이것이 실제 의사결정 근거
□ 6. 독립 라벨(ADT gating 등)로 교차 검증
      → 순환 논리 방지

클러스터 수는 반드시 맞춰서 비교한다. k가 라벨 수보다 크게 적으면 NMI가 구조적으로 깎인다.


6. 메모

이 과정에서 세운 가설 네 개 중 셋이 기각됐다.

가설 결과 기각 근거
클러스터가 batch/donor를 잡고 있다 Seqcore NMI 0.002, SampleID 0.068
Azimuth 라벨이 뭉개져 있다 median score 0.886
ADT depth가 weight를 결정한다 spearman 0.20
WNN의 modality weighting이 포화됐다 Q1=0, Q3=0.996 + 3-way 벤치마크

기각된 가설도 각각 하나씩 가능성을 지웠기 때문에 의미가 있다. 다만 ⑥의 3-way 벤치마크를 먼저 돌렸다면 훨씬 빨리 도착했을 것이다. “왜 안 되는가”를 파기 전에 “무엇이 이기는가”를 먼저 재는 편이 낫다. 원인 규명은 이긴 방법이 없을 때 하는 일이다.

This post is licensed under CC BY 4.0 by the author.

© Subin Cho. Some rights reserved.

Using the Chirpy theme for Jekyll.