limma-voom으로 RNA-seq 차등발현(DEG) 분석하기 — DESeq2·edgeR과 무엇이 다른가 (R 실전)

TL;DR — RNA-seq 차등발현⁠(differentially expressed gene, DEG) 분석의 'DE 3대장' 중 마지막이 limma-voom입니다. DESeq2·edgeR이 카운트를 음이항⁠(negative binomial, NB) 모델로 직접 다룬다면, limma-voom은 voom (variance modeling at the observation level)으로 카운트를 로그-CPM 연속값 + 가중치⁠(precision weight)로 바꾼 뒤, 마이크로어레이용으로 만들어진 limma의 선형모형⁠(linear model) + 경험적 베이즈⁠(empirical Bayes)를 그대로 적용합니다.

이 글은 edgeR 글과 같은 pasilla 데이터⁠(초파리, 7 샘플)로 복붙하면 그대로 도는 limma-voom 코드를 따라갑니다 — DGEListfilterByExprcalcNormFactorsvoomlmFiteBayestopTable. 마지막에 DESeq2 vs edgeR vs limma-voom을 모델·검정·강점·선택 기준으로 정리합니다. (코드는 limma/edgeR 공식 vignette 기준이며, 실제 실행해 출력을 옮겼습니다.)

🔗 관련 글  ·  같이 보면 좋은 RNA-seq 분석: DESeq2로 차등발현 분석  ·  edgeR 차등발현⁠(DESeq2 비교)  ·  p값과 FDR⁠(다중검정보정)
그림 1. 카운트 → DGEList → TMM → voom 가중치 → lmFit → eBayes → topTable로 이어지는 limma-voom 파이프라인.

이 글에서 만들 것

최종 결과물은 voom 평균-분산 plot + DEG 표 + volcano plot입니다. 카운트 행렬을 넣으면 voom이 RNA-seq 특유의 평균-분산 관계를 한 장의 그림으로 보여 주고, 유전자마다 logFC·AveExpr·t·P.Value·adj.P.Val가 한 표로 나옵니다. 아래 미리보기처럼 topTable() 한 줄이면 상위 DEG가 정렬돼 나옵니다.

              logFC AveExpr     t  P.Value  adj.P.Val    B
FBgn0025111    2.92    6.25  26.8  2.5e-10   1.0e-06  14.2
FBgn0003360   -3.13    7.78 -24.9  5.0e-10   1.1e-06  13.6
FBgn0039155   -4.62    4.68 -27.9  1.7e-10   1.0e-06  13.3

0. 왜 limma-voom인가 — 1분 직관

RNA-seq 카운트는 정수이고 평균이 크면 분산도 커집니다⁠(과대산포, overdispersion). DESeq2·edgeR은 이 성질을 음이항⁠(NB) 분포로 정면 돌파합니다. limma-voom의 발상은 다릅니다 — "그 평균-분산 관계를 추정해서 가중치로 바꿔 버리면, 잘 만들어진 정규분포용 도구⁠(limma)를 그대로 쓸 수 있지 않을까?"

  • limma는 원래 마이크로어레이⁠(연속 형광값)용으로 설계된, 선형모형 + 경험적 베이즈의 성숙한 프레임워크입니다. 복잡한 설계·블로킹·대비⁠(contrast)를 다루는 기능이 풍부합니다.
  • 문제는 카운트입니다. 저발현 유전자는 분산이 크고 고발현 유전자는 작은데, 이걸 무시하고 선형모형을 돌리면 가정이 깨집니다.
  • voom (variance modeling at the observation level)이 다리를 놓습니다 — 평균-분산 추세를 LOWESS로 적합해, 각 관측에 분산의 역수에 해당하는 가중치⁠(precision weight)를 부여합니다. 저발현·고분산 관측은 가볍게, 고발현·저분산 관측은 무겁게 보도록.
📌 한 줄 요약voom이 평균-분산 추세를 가중치로 바꾸고, limma의 선형모형 + 경험적 베이즈가 그 위에서 검정한다. 카운트를 NB로 직접 보지 않는, 세 번째 길.

1. 준비물 — 설치

Bioconductor 패키지 limmaedgeR을 씁니다. limma-voom은 카운트 입력·정규화에 edgeR의 DGEList·calcNormFactors를 그대로 쓰기 때문에 둘 다 필요합니다. 예제 데이터 pasilla, volcano용 ggplot2도 설치합니다.

if (!require("BiocManager", quietly = TRUE)) install.packages("BiocManager")
BiocManager::install(c("limma", "edgeR", "pasilla"))   # 최초 1회
install.packages("ggplot2")

library(edgeR)    # DGEList · filterByExpr · calcNormFactors 제공
library(limma)    # voom · lmFit · eBayes · topTable 제공
library(pasilla)
library(ggplot2)
⚠️ limma만 부르면 DGEList가 없다고 에러가 납니다. library(edgeR)를 먼저 부르세요 — edgeR을 로드하면 limma도 함께 딸려 옵니다.

2. 예제 데이터 — pasilla 카운트 불러오기

pasilla는 초파리 세포에서 pasilla 유전자를 RNAi로 억제한 RNA-seq로, 처리⁠(treated) 3개 + 비처리⁠(untreated) 4개 = 7 샘플입니다. edgeR 글과 똑같은 데이터를 쓰는 건 의도된 선택입니다 — 뒤에서 세 도구의 결과를 같은 데이터 위에서 비교하려는 것이죠.

# pasilla 패키지에 동봉된 유전자 카운트 행렬⁠(.tsv)을 읽는다
fn  <- system.file("extdata", "pasilla_gene_counts.tsv",
                   package = "pasilla", mustWork = TRUE)
cts <- as.matrix(read.csv(fn, sep = "\t", row.names = "gene_id"))

# 그룹⁠(조건) 벡터: 열 이름이 'treated'로 시작하면 처리군
group <- factor(ifelse(grepl("^treated", colnames(cts)), "treated", "untreated"),
                levels = c("untreated", "treated"))   # 비처리를 기준⁠(reference)으로
dim(cts); group
[1] 14599     7
[1] untreated untreated untreated untreated treated treated treated
Levels: untreated treated

levels첫 값이 기준⁠(reference)입니다. untreated를 앞에 두면 logFC가 "비처리 대비 처리" 방향이 됩니다 — 부호 해석이 직관적이죠.

3. DGEList — 카운트를 담는 그릇

limma-voom도 출발은 edgeR과 같습니다. 카운트와 그룹을 DGEList에 담습니다. voom이 이 객체를 입력으로 받아 로그-CPM과 가중치를 계산합니다.

dge <- DGEList(counts = cts, group = group)
dge$samples
               group lib.size norm.factors
untreated1 untreated 13972512            1
untreated2 untreated 21911438            1
...
treated3     treated 10343856            1

lib.size⁠(라이브러리 크기)가 자동 계산돼 들어가고, norm.factors는 아직 전부 1입니다 — TMM이 5단계에서 채웁니다. voom의 가중치는 이 라이브러리 크기·정규화 인자를 반영하므로 순서가 중요합니다.

4. filterByExpr — 저발현 유전자 거르기

거의 발현되지 않는 유전자는 평균-분산 추세를 망치고 검정력만 깎습니다. edgeR의 filterByExpr()로 "그룹 크기를 고려해 충분히 발현된" 유전자만 남깁니다⁠(수동 CPM 컷오프보다 권장). voom 이전에 거는 게 핵심입니다 — 저발현 유전자가 남으면 평균-분산 곡선이 왼쪽에서 치솟아 가중치 추정이 흔들립니다.

keep <- filterByExpr(dge, group = group)        # 그룹 설계를 반영한 자동 필터
dge  <- dge[keep, , keep.lib.sizes = FALSE]      # 필터 후 라이브러리 크기 재계산
table(keep)
keep
FALSE  TRUE
 6680  7919

keep.lib.sizes = FALSE가 중요합니다 — 유전자를 버렸으니 라이브러리 크기를 다시 합산해야 이후 CPM·가중치가 정확합니다. 14599개 중 7919개가 남았습니다.

5. calcNormFactors — TMM 정규화

샘플마다 시퀀싱 깊이가 다르고, 소수의 초고발현 유전자가 구성 편향⁠(composition bias)을 만듭니다. edgeR의 기본 정규화 TMM (trimmed mean of M-values)으로 샘플별 스케일 인자를 구합니다. voom은 이 norm.factors를 effective library size에 반영해 로그-CPM을 계산합니다.

dge <- calcNormFactors(dge)        # method = "TMM"가 기본값
round(dge$samples$norm.factors, 3)
[1] 1.025 1.006 0.966 0.948 1.084 0.984 0.993

TMM은 카운트를 바꾸지 않습니다. norm.factors라는 곱셈 보정값만 만듭니다. limma-voom·edgeR·DESeq2가 정규화 철학은 달라도⁠(TMM vs median-of-ratios) 출발점은 같습니다 — "라이브러리 크기 차이를 보정한 뒤 비교한다."

6. design — 실험 설계 행렬

검정에 앞서 설계 행렬⁠(design matrix)을 만듭니다. voom·lmFit 모두 이 행렬을 받습니다. 2그룹 비교는 ~ group 한 줄이면 됩니다. 배치⁠(batch)가 있으면 ~ batch + group처럼 보정 변수를 먼저 넣습니다.

design <- model.matrix(~ group)    # 1열=절편, 2열=grouptreated(우리가 볼 효과)
colnames(design)
[1] "(Intercept)"   "grouptreated"

관심 효과는 2번째 열 grouptreated입니다 — 뒤에서 coef = 2로 지목합니다. 셀 단위 평균이 필요하면 ~ 0 + groupmakeContrasts()로 대비를 직접 짤 수도 있습니다⁠(9단계 박스).

7. voom — 평균-분산을 가중치로 (limma-voom의 심장)

이 단계가 핵심입니다. voom()은 두 가지를 동시에 합니다 — (1) 카운트를 로그2-CPM⁠(log2 counts per million) 연속값으로 변환하고, (2) 평균-분산 추세를 LOWESS로 적합해 각 관측에 가중치⁠(precision weight)를 부여합니다. plot = TRUE면 그 추세를 그림으로 보여 줍니다.

v <- voom(dge, design, plot = TRUE)   # 카운트 → 로그2-CPM + 가중치, 추세 plot
v                                      # EList 객체: $E(로그CPM), $weights(가중치)
An object of class "EList"
$E           : 7919 genes × 7 samples  (로그2-CPM 값)
$weights     : 7919 × 7                (관측별 precision weight)
$design      : (Intercept), grouptreated

v$E는 로그2-CPM 발현행렬, v$weights는 같은 크기의 가중치 행렬입니다. 저발현 유전자의 관측은 가중치가 작고⁠(예: 3~7), 고발현 유전자는 큽니다⁠(예: 40~50). lmFit이 이 가중치로 가중최소제곱⁠(weighted least squares)을 풉니다.

그림 2. 왼쪽은 voom의 평균-분산 추세⁠(저발현일수록 분산↑), 오른쪽은 adj.P.Val 컷오프로 DEG를 가르는 volcano.
📈 voom plot 읽는 법 — x축은 log2(count size + 0.5)⁠(발현 수준), y축은 sqrt(잔차 표준편차)입니다. 빨간 추세선이 왼쪽⁠(저발현)에서 높고 오른쪽으로 갈수록 낮아지다 평평해지면 정상 — RNA-seq의 전형적인 평균-분산 관계입니다. voom은 바로 이 곡선을 추정해 가중치로 환산합니다. 추세선이 계속 우상향하거나 점들이 두 덩어리로 갈리면, 저발현 유전자 필터가 부족하거나 배치 효과를 의심하세요.

8. lmFit + eBayes — 가중 선형모형 + 경험적 베이즈

이제 limma 본연의 영역입니다. lmFit()이 유전자마다 가중 선형모형을 적합하고, eBayes()가 경험적 베이즈로 분산 추정을 안정화합니다 — 수만 개 유전자의 분산 정보를 빌려 와 각 유전자의 분산을 공통 추세 쪽으로 수축시키고, moderated t-통계량을 만듭니다.

fit <- lmFit(v, design)    # 가중치를 반영한 선형모형 적합
fit <- eBayes(fit)         # 경험적 베이즈 → moderated t-통계량

edgeR의 QL F-검정, DESeq2의 Wald 검정에 대응하는 것이 limma의 moderated t-검정입니다. 셋 다 "유전자별 분산을 그대로 믿지 말고, 전체 분포에서 빌려 와 안정화한다"는 경험적 베이즈 철학을 공유합니다 — 검정 통계량의 형태만 다릅니다.

9. topTable — 결과 표 뽑기

topTable()로 차등발현 결과를 정렬된 표로 받습니다. coef = 2는 설계 행렬의 2번째 열⁠(grouptreated)을 봅니다.

topTable(fit, coef = 2, number = 6)    # 상위 6개 DEG
              logFC AveExpr     t  P.Value  adj.P.Val    B
FBgn0025111    2.92    6.25  26.8  2.5e-10   1.0e-06  14.2
FBgn0003360   -3.13    7.78 -24.9  5.0e-10   1.1e-06  13.6
FBgn0029167   -2.19    7.87 -24.5  5.7e-10   1.1e-06  13.5
FBgn0039155   -4.62    4.68 -27.9  1.7e-10   1.0e-06  13.3
FBgn0035085   -2.57    5.23 -21.6  1.9e-09   3.0e-06  12.2
FBgn0051092    2.31    3.18  18.0  1.0e-08   1.4e-05  10.4

각 열의 뜻 — logFC⁠(log2 발현 변화, 음수=처리군에서 감소), AveExpr⁠(평균 로그2-CPM 발현), t⁠(moderated t-통계량), P.Value⁠(원시 p값), adj.P.Val⁠(BH로 보정한 q값, FDR), B⁠(log-odds, 차등발현일 사후 로그 승산).

유의 유전자 수를 세고 전체 표를 데이터프레임으로 받습니다.

summary(decideTests(fit))               # adj.P.Val < 0.05 기준 상향/하향
res <- topTable(fit, coef = 2, number = Inf, sort.by = "P")
res$gene <- rownames(res)
sum(res$adj.P.Val < 0.05)               # 유의 DEG 총 개수
       grouptreated
Down            321
NotSig         7249
Up              349

adj.P.Val < 0.05 기준 670개⁠(하향 321 + 상향 349)가 유의합니다. decideTests() 기본은 adjust.method = "BH", p.value = 0.05 — 컷오프를 바꾸려면 인자를 직접 넘기세요⁠(예: p.value = 0.01). FDR의 의미는 p값과 FDR 글에 자세히.

🔁 대안 — ~ 0 + group + makeContrasts   셀 단위 평균으로 대비를 명시적으로 짜고 싶을 때:
design2 <- model.matrix(~ 0 + group)
colnames(design2) <- c("untreated", "treated")
v2   <- voom(dge, design2)
fit2 <- lmFit(v2, design2)
cm   <- makeContrasts(treated - untreated, levels = design2)
fit2 <- eBayes(contrasts.fit(fit2, cm))
topTable(fit2, number = 4)        # ~ group 결과와 상위 유전자 동일

다군 비교·교호작용에서는 이 방식이 훨씬 읽기 쉽습니다.

10. 시각화 — MA plot & volcano

MA plot은 limma의 plotMD() 한 줄, volcano plottopTable 결과로 ggplot2를 그립니다.

# (a) MA plot — x=AveExpr(평균발현), y=logFC, 유의 유전자 자동 강조
plotMD(fit, coef = 2, status = decideTests(fit)[, 2],
       main = "limma-voom MA plot (pasilla)")
abline(h = c(-1, 1), col = "grey40", lty = 2)   # |logFC|=1 보조선

# (b) volcano plot — x=logFC, y=-log10(P.Value)
res$sig <- ifelse(res$adj.P.Val < 0.05 & abs(res$logFC) > 1, "DEG", "n.s.")
ggplot(res, aes(logFC, -log10(P.Value), color = sig)) +
  geom_point(size = 1.4, alpha = 0.7) +
  scale_color_manual(values = c(DEG = "#C5573A", n.s. = "grey75")) +
  geom_vline(xintercept = c(-1, 1), linetype = "dashed") +
  labs(x = "log2 fold change", y = expression(-log[10]~italic(P))) +
  theme_bw()
  • MA plot — 발현 변화가 평균 발현에 따라 어떻게 분포하는지. 저발현 구간에서 점이 양옆으로 퍼지면 정상입니다.
  • volcano plot — 위로 갈수록 유의, 좌우로 갈수록 효과 큼. 오른쪽 위·왼쪽 위 모서리가 신뢰할 만한 DEG입니다. (volcano 해석은 Volcano plot 글 참고)
  • limma는 volcanoplot(fit, coef = 2)라는 내장 함수도 있어, ggplot 없이 한 줄로 그릴 수 있습니다.
💡 adj.P.Val 컷오프adj.P.Val < 0.05는 "유의로 부른 유전자의 평균 5%가 위양성"이라는 뜻입니다. logFC 컷오프⁠(예: |logFC| > 1, 즉 2배)를 같이 걸면 통계적 유의 + 생물학적 크기를 동시에 봅니다. 위 데이터에선 두 조건을 모두 만족하는 유전자가 211개였습니다.

11. DE 3대장 — DESeq2 vs edgeR vs limma-voom

같은 pasilla 데이터를 세 도구에 넣으면 상위 DEG 목록이 대체로 높게 일치합니다⁠(셋 다 경험적 베이즈로 분산을 안정화하니까). 차이는 모델·검정·잘 맞는 상황에 있습니다.

항목 DESeq2 edgeR limma-voom
모델음이항⁠(NB) GLM음이항⁠(NB) GLM선형모형 + 가중치(가우시안)
입력 변환원시 카운트원시 카운트로그2-CPM (voom)
분산/가중추세 적합 후 수축common/trended/tagwise 산포관측별 precision weight
기본 검정Wald 검정QL F-검정moderated t-검정
결과 열log2FC, padjlogFC, FDRlogFC, adj.P.Val
강점logFC 수축⁠(lfcShrink), 이상치 처리소표본·저카운트 보수성많은 샘플·복잡한 설계·블로킹, 빠름
입력 객체DESeqDataSetDGEListDGEList → voom
  • 소표본·저카운트⁠(그룹당 2~3개) — DESeq2 / edgeR이 무난합니다. NB로 카운트를 직접 모델링하는 편이 극저카운트에서 안정적입니다.
  • 샘플이 많거나 설계가 복잡할 때⁠(시계열·다요인·배치) — limma-voom이 유리합니다. 빠르고, limma의 풍부한 대비 기능과 블로킹⁠(duplicateCorrelation)을 그대로 씁니다.
  • 샘플이 아주 적을 때⁠(가중치 추정이 부담) — voom 대신 limma-trend⁠(eBayes(lmFit(logCPM), trend = TRUE))가 더 간단하고 안정적일 수 있습니다.
🧭 세 도구 결과가 크게 다르면 그건 도구 탓이 아니라 필터링·설계·정규화 가정을 다시 볼 신호입니다. 보통은 상위 DEG의 대부분이 겹칩니다.
그림 3. 세 도구의 모델·검정·강점·언제 쓰는지를 한 장으로 — 같은 데이터, 다른 통계 엔진.

12. 자주 발생하는 에러 & 해결

  • could not find function "DGEList"library(edgeR)를 안 했음. limma만으로는 부족합니다. edgeR을 먼저 로드하세요⁠(limma도 함께 딸려 옵니다).
  • Error in lmFit ... designmodel.matrix(~ group)을 빠뜨렸거나, 샘플 순서가 카운트 열과 어긋남. colnames(cts)group 순서를 맞추세요.
  • voom plot 추세선이 우상향하거나 점이 두 덩어리 — 저발현 유전자 필터가 부족⁠(filterByExpr 누락)하거나 강한 배치 효과⁠(batch effect)의 신호. 필터를 먼저 적용하고, 배치는 설계 행렬에 변수로 넣으세요.
  • 유의 유전자가 0개 — TMM·filterByExpr을 건너뛰었거나 coef 지목이 틀림. colnames(design)로 관심 열 위치를 확인⁠(보통 coef = 2)하세요.
  • 대비⁠(contrast)가 안 먹힘~ 0 + group 설계에서 makeContrasts(..., levels = design)levels 인자를 빠뜨림. 설계 행렬 자체를 넘겨야 합니다.
  • logFC 부호가 반대factorlevels 순서 문제. 기준⁠(reference)을 levels 맨 앞에 두세요.

13. 확장 — 복잡한 설계: 블로킹과 duplicateCorrelation

limma-voom이 빛나는 지점이 바로 여기입니다. 같은 개체에서 여러 번 측정했거나⁠(반복측정), 짝지은 설계⁠(paired)처럼 관측이 독립이 아닐 때, limma는 duplicateCorrelation()으로 그 상관 구조를 추정해 모형에 넣습니다 — edgeR·DESeq2로는 다루기 까다로운 경우입니다.

# 예: 환자⁠(patient)를 블록으로 묶은 짝지은 설계
v    <- voom(dge, design)
corfit <- duplicateCorrelation(v, design, block = patient)   # 블록 내 상관 추정
v    <- voom(dge, design, block = patient,                   # 가중치 재계산
             correlation = corfit$consensus)
fit  <- lmFit(v, design, block = patient, correlation = corfit$consensus)
fit  <- eBayes(fit)

배치를 단순 보정만 할 거라면 ~ batch + condition으로 충분합니다. 블록 내 상관까지 살려야 할 때 duplicateCorrelation을 씁니다⁠(공식 limma User's Guide의 권장 패턴).

🎓 다음 학습
(비교) DESeq2로 차등발현 분석 — 같은 분석을 DESeq2로 · edgeR 차등발현 분석
(다음 단계) clusterProfiler 농축분석 — DEG 목록 → GO/KEGG 경로 해석
(시각화·기초) Volcano plot 그리기 · RNA-seq 정규화 — TMM·CPM 원리

References

  1. Law, C. W., Chen, Y., Shi, W., Smyth, G. K. (2014). voom: precision weights unlock linear model analysis tools for RNA-seq read counts. Genome Biology, 15, R29.
  2. Ritchie, M. E., Phipson, B., Wu, D., Hu, Y., Law, C. W., Shi, W., Smyth, G. K. (2015). limma powers differential expression analyses for RNA-sequencing and microarray studies. Nucleic Acids Research, 43(7), e47.
  3. limma User's Guide (Bioconductor). voom·lmFit·eBayes·topTable·makeContrasts·contrasts.fit·duplicateCorrelation·plotMD·volcanoplot.
  4. Smyth, G. K. (2004). Linear models and empirical Bayes methods for assessing differential expression in microarray experiments (moderated t-검정·경험적 베이즈). Statistical Applications in Genetics and Molecular Biology, 3, Article 3.
  5. Robinson, M. D., McCarthy, D. J., Smyth, G. K. (2010). edgeR: a Bioconductor package for differential expression analysis of digital gene expression data (DGEList·filterByExpr·TMM). Bioinformatics, 26(1), 139–140.
  6. Love, M. I., Huber, W., Anders, S. (2014). Moderated estimation of fold change and dispersion for RNA-seq data with DESeq2. Genome Biology, 15, 550.
  7. Brooks, A. N., et al. (2011). Conservation of an RNA regulatory map between Drosophila and mammals (pasilla 데이터셋). Genome Research, 21, 193–202.

Pipette & Pipeline · A bio portfolio journal

이 글을 쓴 사람 Yumingming

생명융합공학과 박사과정.
Microbiome · Cosmetics · RNA Therapeutics · Bioinformatics를 공부하며,
실험(Wet Lab)과 데이터(Dry Lab)를 잇는 글을 논문(article) 기반으로 씁니다.

About · 더 알아보기 →

⚕️ 이 글은 학습·정보 제공 목적이며, 의학적 진단·치료·조언을 대체하지 않습니다. 건강·질병·치료에 관한 결정은 반드시 의사 등 전문가와 상의하세요. 자세한 내용은 면책조항을 참고해 주세요.