临床问题:新辅助化疗可以缩小肿瘤、利于手术完全切除,并清除微小转移以降低复发风险。但不同患者对化疗的敏感性差异很大,临床上需要能够预测疗效/复发的分子标志物。
本数据 GSE19697(Lin et al., Clin Cancer Res 2009, PMID 19967557)是华盛顿大学一项新辅助化疗试验的基线(化疗前)肿瘤活检表达谱:
| 项目 | 内容 |
|---|---|
| 平台 | Affymetrix HG-U133 Plus 2.0(GPL570,54 675 个探针集) |
| 样本 | 24 例化疗前 core biopsy(来自 70 例中的 basal-like 亚组) |
| 分组(本分析) | recur = 1 术后复发 vs
recur = 0 无复发 |
| 其他临床变量 | ER / PR / HER2 状态、组织学分级、阳性淋巴结数、T 分期、种族等 |
| 数据来源 | GEO: GSE19697 |
原始数据有两个层次,本流程两者都会用到:
GSE19697_series_matrix.txt.gz:作者用
MAS 5.0 处理好的表达值(线性尺度、scaled to target intensity
1500),同时携带全部样本临床注释。GSE19697_RAW.tar:24 个原始
.CEL 文件,用于从原始荧光强度出发做标准化的
RMA 处理,是更规范、可复现的做法。分析策略:以 CEL → RMA 为主流程;若原始 CEL 不存在,则退化为 series matrix 的 MAS 5.0 值 +
log2+ 分位数标准化。两条路径的结果会在最后一节做一致性比较。
library(limma) # 差异表达分析(线性模型 + 经验贝叶斯)
library(affy) # 读取 CEL、RMA 标准化
library(hgu133plus2.db) # HG-U133 Plus 2.0 探针注释
library(AnnotationDbi)
library(Biobase)
library(ggplot2)
library(ggrepel)
library(pheatmap)
library(RColorBrewer)
library(matrixStats)
set.seed(2026)
data_dir <- "data" # 数据目录(与 Rmd 同级)
cel_dir <- file.path(data_dir, "CEL") # CEL 文件目录
res_dir <- "results" # 结果输出目录
dir.create(cel_dir, recursive = TRUE, showWarnings = FALSE)
dir.create(res_dir, recursive = TRUE, showWarnings = FALSE)
sm_file <- file.path(data_dir, "GSE19697_series_matrix.txt.gz")
raw_file <- file.path(data_dir, "GSE19697_RAW.tar")
sm_url <- "https://ftp.ncbi.nlm.nih.gov/geo/series/GSE19nnn/GSE19697/matrix/GSE19697_series_matrix.txt.gz"
raw_url <- "https://ftp.ncbi.nlm.nih.gov/geo/series/GSE19nnn/GSE19697/suppl/GSE19697_RAW.tar"
# 已下载过就跳过,避免重复下载(series matrix 约 3 MB,RAW tar 约 112 MB)
download_if_missing <- function(url, dest) {
if (!file.exists(dest) || file.size(dest) == 0) {
message("正在下载 ", basename(dest), " ...")
utils::download.file(url, dest, mode = "wb", quiet = TRUE)
} else {
message("已存在,跳过下载:", basename(dest))
}
}
download_if_missing(sm_url, sm_file)
download_if_missing(raw_url, raw_file)
# base R 解压 gzip,避免引入额外依赖
gunzip_file <- function(f) {
out <- sub("\\.gz$", "", f)
con_in <- gzfile(f, "rb")
con_out <- file(out, "wb")
repeat {
chunk <- readBin(con_in, "raw", n = 1e6)
if (length(chunk) == 0) break
writeBin(chunk, con_out)
}
close(con_in); close(con_out)
unlink(f)
out
}
# RAW tar -> CEL/*.CEL
cel_files <- list.files(cel_dir, pattern = "\\.CEL$", full.names = TRUE)
if (length(cel_files) == 0) {
utils::untar(raw_file, exdir = cel_dir)
for (f in list.files(cel_dir, pattern = "\\.CEL\\.gz$", full.names = TRUE)) gunzip_file(f)
cel_files <- list.files(cel_dir, pattern = "\\.CEL$", full.names = TRUE)
}
cel_files <- cel_files[order(basename(cel_files))] # 按 GSM 编号排序,与临床表一致
knitr::kable(
data.frame(
文件 = c(basename(sm_file), basename(raw_file), paste0("CEL 文件(", length(cel_files), " 个)")),
大小 = c(paste(round(file.size(sm_file) / 1024^2, 1), "MB"),
paste(round(file.size(raw_file) / 1024^2, 1), "MB"),
paste(round(sum(file.size(cel_files)) / 1024^2, 1), "MB")),
路径 = c(data_dir, data_dir, cel_dir)
),
caption = "已就绪的数据文件"
)
| 文件 | 大小 | 路径 |
|---|---|---|
| GSE19697_series_matrix.txt.gz | 3.2 MB | data |
| GSE19697_RAW.tar | 106.8 MB | data |
| CEL 文件(24 个) | 310.2 MB | data/CEL |
series matrix 的注释信息全部写在文件头部的 !Sample_*
行里(每行一个变量,制表符分隔各样本)。下面用 base R 直接解析,便于看清
SOFT / GEOSoft 文件的结构。
sm_lines <- readLines(gzfile(sm_file), warn = FALSE)
# 取出某个 "!Sample_xxx" 行里所有样本的取值
parse_row <- function(lines, tag) {
hit <- grep(paste0("^!", tag, "\t"), lines, value = TRUE)
if (length(hit) == 0) return(NULL)
gsub('^"|"$', "", strsplit(hit[1], "\t")[[1]][-1])
}
# 每个 !Sample_characteristics_ch1 行是一种临床变量(key: value 形式),本数据共 12 行
char_lines <- grep("^!Sample_characteristics_ch1\t", sm_lines, value = TRUE)
char_list <- lapply(char_lines, function(l) {
v <- gsub('^"|"$', "", strsplit(l, "\t")[[1]][-1])
sub("^[^:]*:\\s*", "", v) # 去掉 "key: " 前缀,只留取值
})
names(char_list) <- vapply(char_lines, function(l) {
sub(":.*$", "", gsub('^"', "", strsplit(l, "\t")[[1]][2])) # 变量名取第一条记录的 key
}, character(1))
pheno <- data.frame(gsm = parse_row(sm_lines, "Sample_geo_accession"),
title = parse_row(sm_lines, "Sample_title"),
as.data.frame(char_list, check.names = FALSE, stringsAsFactors = FALSE),
check.names = FALSE)
# 变量重命名 + 类型转换(recur:1 = 术后复发,0 = 无复发)
pheno <- within(pheno, {
recurrence <- factor(ifelse(recur == "1", "Recurrence", "NoRecurrence"),
levels = c("NoRecurrence", "Recurrence"))
# 显式给出 levels,某些变量在本队列中只有一个水平(如 PR 全为阴性)
er <- factor(er, levels = c("0", "1"), labels = c("negative", "positive"))
pr <- factor(pr, levels = c("0", "1"), labels = c("negative", "positive"))
her2 <- factor(her2, levels = c("0", "1"), labels = c("negative", "positive"))
grade <- factor(grade)
t_stage <- factor(`t stage`)
pos_nodes <- as.numeric(`positive nodes`)
nodes_exam <- as.numeric(`nodes examined`)
race <- factor(race)
})
cat("样本数:", nrow(pheno),
" 无复发:", sum(pheno$recurrence == "NoRecurrence"),
" 复发:", sum(pheno$recurrence == "Recurrence"), "\n")
样本数: 24 无复发: 18 复发: 6
knitr::kable(
data.frame(GSM = pheno$gsm,
样本 = sub("Pre-Treatment breast tumor ", "tumor ", pheno$title),
复发 = as.character(pheno$recurrence),
ER = pheno$er, PR = pheno$pr, HER2 = pheno$her2,
分级 = pheno$grade, 阳性淋巴结 = pheno$pos_nodes, T分期 = pheno$t_stage),
caption = "24 例样本的临床注释(解析自 series matrix)"
)
| GSM | 样本 | 复发 | ER | PR | HER2 | 分级 | 阳性淋巴结 | T分期 |
|---|---|---|---|---|---|---|---|---|
| GSM491610 | tumor 7411 | NoRecurrence | negative | negative | positive | 3 | 0 | 2 |
| GSM491611 | tumor 7412 | Recurrence | negative | negative | negative | 3 | 0 | 2 |
| GSM491612 | tumor 7415 | Recurrence | negative | negative | negative | 3 | 3 | 2 |
| GSM491613 | tumor 7421 | NoRecurrence | negative | negative | negative | 2 | 0 | 2 |
| GSM491614 | tumor 7422 | NoRecurrence | negative | negative | negative | 3 | 0 | 3 |
| GSM491615 | tumor 8159 | NoRecurrence | negative | negative | negative | 3 | 5 | 2 |
| GSM491616 | tumor 8170 | NoRecurrence | negative | negative | negative | 3 | 0 | 2 |
| GSM491617 | tumor 8189 | NoRecurrence | negative | negative | negative | 3 | 0 | 2 |
| GSM491618 | tumor 8190 | NoRecurrence | negative | negative | negative | 3 | 1 | 2 |
| GSM491619 | tumor 8191 | Recurrence | negative | negative | negative | 3 | 0 | 2 |
| GSM491620 | tumor 8199 | NoRecurrence | negative | negative | negative | 3 | 0 | 1 |
| GSM491621 | tumor 8200 | NoRecurrence | negative | negative | negative | 3 | 1 | 2 |
| GSM491622 | tumor 8209 | Recurrence | negative | negative | positive | 3 | 1 | 2 |
| GSM491623 | tumor 8210 | NoRecurrence | positive | negative | negative | 3 | 0 | 2 |
| GSM491624 | tumor 8413 | Recurrence | negative | negative | negative | 2 | 10 | 3 |
| GSM491625 | tumor 11519 | NoRecurrence | negative | negative | negative | 3 | 0 | 2 |
| GSM491626 | tumor 11524 | Recurrence | negative | negative | negative | 3 | 14 | 3 |
| GSM491627 | tumor 11526 | NoRecurrence | negative | negative | negative | 3 | 0 | 2 |
| GSM491628 | tumor 11529 | NoRecurrence | negative | negative | negative | 3 | 1 | 2 |
| GSM491629 | tumor 11530 | NoRecurrence | negative | negative | negative | 3 | 0 | 2 |
| GSM491630 | tumor 11532 | NoRecurrence | negative | negative | negative | 3 | 0 | 2 |
| GSM491631 | tumor 11571 | NoRecurrence | negative | negative | negative | 3 | 0 | 2 |
| GSM491632 | tumor 11582 | NoRecurrence | negative | negative | negative | 3 | 0 | 2 |
| GSM491633 | tumor 11585 | NoRecurrence | negative | negative | negative | 3 | 2 | 2 |
# 两组临床特征的分布(小样本,仅作描述性统计)
clin_vars <- c("er", "pr", "her2", "grade", "t_stage", "race")
tab <- do.call(rbind, lapply(clin_vars, function(v) {
t <- table(pheno[[v]], pheno$recurrence)
data.frame(变量 = v, 水平 = rownames(t),
无复发 = t[, "NoRecurrence"], 复发 = t[, "Recurrence"], row.names = NULL)
}))
knitr::kable(tab, caption = "两组临床特征分布")
| 变量 | 水平 | 无复发 | 复发 |
|---|---|---|---|
| er | negative | 17 | 6 |
| er | positive | 1 | 0 |
| pr | negative | 18 | 6 |
| pr | positive | 0 | 0 |
| her2 | negative | 17 | 5 |
| her2 | positive | 1 | 1 |
| grade | 2 | 1 | 1 |
| grade | 3 | 17 | 5 |
| t_stage | 1 | 1 | 0 |
| t_stage | 2 | 16 | 4 |
| t_stage | 3 | 1 | 2 |
| race | African American | 8 | 2 |
| race | Caucasian | 10 | 4 |
cat("\n阳性淋巴结数(均值 ± 标准差):无复发 ",
sprintf("%.2f ± %.2f", mean(pheno$pos_nodes[pheno$recurrence == "NoRecurrence"]),
sd(pheno$pos_nodes[pheno$recurrence == "NoRecurrence"])),
",复发 ",
sprintf("%.2f ± %.2f", mean(pheno$pos_nodes[pheno$recurrence == "Recurrence"]),
sd(pheno$pos_nodes[pheno$recurrence == "Recurrence"])), "\n")
阳性淋巴结数(均值 ± 标准差):无复发 0.56 ± 1.25 ,复发 4.67 ± 5.92
series matrix 的表体以 ID_REF 行开头,最后一行是
!series_matrix_table_end
标记,读入后必须把它剔除,否则会多出一行全为缺失值的”探针”。本文件表体含
54 674 个探针集,比芯片设计的 54 675 个少 1
个(作者提交数据时的差异,不影响分析)。
skip_n <- grep("^\"?ID_REF", sm_lines)[1]
expr_mas5 <- as.matrix(read.delim(gzfile(sm_file), skip = skip_n, header = TRUE,
row.names = 1, check.names = FALSE))
# 剔除表尾的 "!series_matrix_table_end" 行(不含任何表达值)
expr_mas5 <- expr_mas5[!startsWith(rownames(expr_mas5), "!"), , drop = FALSE]
storage.mode(expr_mas5) <- "double"
stopifnot(!anyNA(expr_mas5))
cat(sprintf("MAS 5.0 表达矩阵:%d 个探针集 × %d 个样本\n", nrow(expr_mas5), ncol(expr_mas5)))
MAS 5.0 表达矩阵:54674 个探针集 × 24 个样本
cat(sprintf("取值范围:%.1f ~ %.1f(线性尺度,尚未做 log 转换)\n", min(expr_mas5), max(expr_mas5)))
取值范围:0.6 ~ 531159.8(线性尺度,尚未做 log 转换)
RMA 一个函数就完成了四步:背景校正 → log2 转换 → 分位数标准化 → 探针集汇总(median polish),因此 RMA 结果无需再做额外标准化。
use_cel <- TRUE # TRUE:用 RAW tar 里的 CEL 走 RMA;FALSE:退化为 MAS5 + 分位数标准化
cel_ok <- use_cel && length(cel_files) == nrow(pheno) &&
requireNamespace("hgu133plus2cdf", quietly = TRUE)
if (cel_ok) {
abatch <- ReadAffy(filenames = cel_files) # 原始 CEL:未做任何标准化
raw_pm <- log2(pmax(exprs(abatch), 1)) # 标准化前的 PM 强度(log2 仅为可视化)
eset <- rma(abatch) # RMA 标准化
expr_norm <- exprs(eset)
} else {
message("未使用 CEL:退化为 series matrix 的 MAS 5.0 值 + log2 + 分位数标准化")
raw_pm <- log2(pmax(expr_mas5, 1))
expr_norm <- normalizeBetweenArrays(raw_pm, method = "quantile")
}
Background correcting
Normalizing
Calculating Expression
# ReadAffy 保留 ".CEL" 扩展名,统一成 GSM 编号后再与临床表对应
colnames(raw_pm) <- sub("[.]CEL$", "", basename(colnames(raw_pm)))
colnames(expr_norm) <- sub("[.]CEL$", "", basename(colnames(expr_norm)))
# 让表达矩阵的列顺序与临床表严格对应
pheno <- pheno[match(colnames(expr_norm), pheno$gsm), , drop = FALSE]
stopifnot(!anyNA(pheno$gsm), identical(colnames(expr_norm), pheno$gsm))
cat(sprintf("标准化后表达矩阵:%d 个探针集 × %d 个样本(log2 尺度)\n",
nrow(expr_norm), ncol(expr_norm)))
标准化后表达矩阵:54675 个探针集 × 24 个样本(log2 尺度)
par(mfrow = c(1, 2), mar = c(7, 4, 3, 1))
boxplot(raw_pm, las = 2, outline = FALSE, cex.axis = 0.8,
main = "标准化前(原始 PM 强度,log2)", ylab = expression(log[2]~"intensity"))
boxplot(expr_norm, las = 2, outline = FALSE, cex.axis = 0.8,
main = if (cel_ok) "RMA 标准化后" else "分位数标准化后",
ylab = expression(log[2]~"expression"))
par(mfrow = c(1, 1))
# 密度图:标准化后各样本的分布应基本重合
par(mfrow = c(1, 2), mar = c(4, 4, 3, 1))
plotDensities(raw_pm, legend = FALSE, main = "标准化前")
plotDensities(expr_norm, legend = FALSE,
main = if (cel_ok) "RMA 标准化后" else "分位数标准化后")
par(mfrow = c(1, 1))
# 用前 5000 个高变探针集做 PCA,观察样本整体分布是否与分组有关
probe_var <- rowVars(expr_norm)
top_var <- order(probe_var, decreasing = TRUE)[seq_len(5000)]
pca <- prcomp(t(expr_norm[top_var, ]), scale. = TRUE)
pv <- round(100 * pca$sdev^2 / sum(pca$sdev^2), 1)
pca_df <- data.frame(PC1 = pca$x[, 1], PC2 = pca$x[, 2],
recurrence = pheno$recurrence)
ggplot(pca_df, aes(PC1, PC2, color = recurrence)) +
geom_point(size = 3.5, alpha = 0.9) +
stat_ellipse(level = 0.68, linetype = 2) +
labs(title = "PCA(前 5000 个高变探针集)",
x = paste0("PC1 (", pv[1], "%)"), y = paste0("PC2 (", pv[2], "%)"))
anno <- AnnotationDbi::select(hgu133plus2.db, keys = rownames(expr_norm),
keytype = "PROBEID",
columns = c("SYMBOL", "ENTREZID", "GENENAME"))
anno <- anno[!is.na(anno$SYMBOL), ]
anno <- anno[!duplicated(anno$PROBEID), ]
cat(sprintf("探针集 %d 个,其中 %d 个可注释到基因符号,涉及 %d 个基因\n",
nrow(expr_norm), nrow(anno), length(unique(anno$SYMBOL))))
探针集 54675 个,其中 44647 个可注释到基因符号,涉及 21355 个基因
# 一个基因对应多个探针时,保留平均表达最高的探针(基因水平汇总常用做法)
anno$probe_mean <- rowMeans(expr_norm[anno$PROBEID, , drop = FALSE])
anno_sorted <- anno[order(anno$SYMBOL, -anno$probe_mean), ]
keep_probe <- anno_sorted$PROBEID[!duplicated(anno_sorted$SYMBOL)]
expr_gene <- expr_norm[keep_probe, , drop = FALSE]
rownames(expr_gene) <- anno_sorted$SYMBOL[match(keep_probe, anno_sorted$PROBEID)]
expr_gene <- expr_gene[order(rownames(expr_gene)), , drop = FALSE]
cat(sprintf("基因水平表达矩阵:%d 个基因 × %d 个样本\n", nrow(expr_gene), ncol(expr_gene)))
基因水平表达矩阵:21355 个基因 × 24 个样本
group <- pheno$recurrence
design <- model.matrix(~ group) # 截距 + groupRecurrence(复发相对于无复发的 log2 差异)
colnames(design) <- c("Intercept", "Recurrence_vs_NoRecurrence")
fit <- lmFit(expr_norm, design) # 探针集水平拟合
fit <- eBayes(fit) # 经验贝叶斯收缩,提高小样本下的稳定性
tt <- topTable(fit, coef = "Recurrence_vs_NoRecurrence", number = Inf, sort.by = "P")
tt$probe_id <- rownames(tt)
tt$symbol <- anno$SYMBOL[match(tt$probe_id, anno$PROBEID)]
tt <- tt[, c("probe_id", "symbol", "logFC", "AveExpr", "t", "P.Value", "adj.P.Val", "B")]
# 显著性判定:FDR < 0.05 且 |log2FC| > 1
sig <- decideTests(fit, p.value = 0.05, lfc = 1, adjust.method = "BH")
cat("差异表达探针集统计(FDR < 0.05, |log2FC| > 1):\n")
差异表达探针集统计(FDR < 0.05, |log2FC| > 1):
print(summary(sig))
Intercept Recurrence_vs_NoRecurrence
Down 0 0
NotSig 0 54675
Up 54675 0
de_fdr <- subset(tt, adj.P.Val < 0.05 & abs(logFC) > 1)
cat(sprintf("\n严格标准共筛出 %d 个探针集,最小 FDR = %.3f\n", nrow(de_fdr), min(tt$adj.P.Val)))
严格标准共筛出 0 个探针集,最小 FDR = 1.000
cat("复发组仅 6 例,经 54 675 次检验的 BH 校正后没有探针集达到 FDR < 0.05,\n")
复发组仅 6 例,经 54 675 次检验的 BH 校正后没有探针集达到 FDR < 0.05,
cat("这是小样本芯片研究的常见结果,因此下面另用一个探索性标准(名义 P 值)筛选候选基因。\n\n")
这是小样本芯片研究的常见结果,因此下面另用一个探索性标准(名义 P 值)筛选候选基因。
# 探索性判定:名义 P < 0.05 且 |log2FC| > 1 —— 仅用于候选基因筛选与可视化,必须外部验证
cand <- subset(tt, P.Value < 0.05 & abs(logFC) > 1)
cand <- cand[order(cand$P.Value), ]
cat(sprintf("探索性标准(P < 0.05 且 |logFC| > 1):%d 个探针集,上调 %d 个,下调 %d 个\n",
nrow(cand), sum(cand$logFC > 0), sum(cand$logFC < 0)))
探索性标准(P < 0.05 且 |logFC| > 1):39 个探针集,上调 27 个,下调 12 个
knitr::kable(head(tt[!is.na(tt$symbol), ], 20), digits = 3,
caption = "按 P 值排序的前 20 个探针集(Recurrence vs NoRecurrence)")
| probe_id | symbol | logFC | AveExpr | t | P.Value | adj.P.Val | B | |
|---|---|---|---|---|---|---|---|---|
| 210501_x_at | 210501_x_at | EIF3K | -0.811 | 10.287 | -4.594 | 0.000 | 1 | -2.517 |
| 201441_at | 201441_at | COX6B1 | -0.725 | 10.366 | -4.525 | 0.000 | 1 | -2.558 |
| 225969_at | 225969_at | ALKBH6 | -0.854 | 6.242 | -4.485 | 0.000 | 1 | -2.581 |
| 221494_x_at | 221494_x_at | EIF3K | -0.775 | 10.339 | -4.301 | 0.000 | 1 | -2.693 |
| 203262_s_at | 203262_s_at | FAM50A | -0.675 | 8.013 | -4.109 | 0.000 | 1 | -2.811 |
| 227138_at | 227138_at | CRTAP | 0.566 | 5.773 | 4.043 | 0.000 | 1 | -2.853 |
| 202053_s_at | 202053_s_at | ALDH3A2 | -1.122 | 8.133 | -4.032 | 0.000 | 1 | -2.859 |
| 212716_s_at | 212716_s_at | EIF3K | -0.707 | 10.654 | -4.002 | 0.001 | 1 | -2.878 |
| 224913_s_at | 224913_s_at | TIMM50 | -0.748 | 8.070 | -3.901 | 0.001 | 1 | -2.943 |
| 202475_at | 202475_at | TMEM147 | -0.732 | 9.806 | -3.864 | 0.001 | 1 | -2.966 |
| 212955_s_at | 212955_s_at | POLR2I | -0.718 | 8.915 | -3.856 | 0.001 | 1 | -2.971 |
| 226437_at | 226437_at | YIF1B | -0.814 | 7.607 | -3.844 | 0.001 | 1 | -2.979 |
| 1552414_at | 1552414_at | WFDC9 | 0.382 | 3.463 | 3.803 | 0.001 | 1 | -3.005 |
| 1555235_s_at | 1555235_s_at | IQCF3 | 0.392 | 3.526 | 3.732 | 0.001 | 1 | -3.051 |
| 224467_s_at | 224467_s_at | PDCD2L | -0.813 | 7.225 | -3.726 | 0.001 | 1 | -3.055 |
| 203343_at | 203343_at | UGDH | 0.718 | 7.099 | 3.696 | 0.001 | 1 | -3.075 |
| 215154_at | 215154_at | ULK2 | 0.381 | 4.507 | 3.687 | 0.001 | 1 | -3.080 |
| 218702_at | 218702_at | SARS2 | -0.556 | 5.251 | -3.677 | 0.001 | 1 | -3.087 |
| 200820_at | 200820_at | PSMD8 | -0.692 | 9.705 | -3.669 | 0.001 | 1 | -3.092 |
| 211047_x_at | 211047_x_at | AP2S1 | -0.524 | 9.801 | -3.653 | 0.001 | 1 | -3.102 |
# 按探索性标准着色(P < 0.05 且 |log2FC| > 1)
tt$sig_class <- factor(
ifelse(tt$P.Value < 0.05 & tt$logFC > 1, "Up in recurrence",
ifelse(tt$P.Value < 0.05 & tt$logFC < -1, "Down in recurrence", "Not significant")),
levels = c("Up in recurrence", "Down in recurrence", "Not significant"))
# 标注最显著的候选基因(前 15 个有基因符号的)
lab <- head(tt[!is.na(tt$symbol) & tt$sig_class != "Not significant", ], 15)
ggplot(tt, aes(logFC, -log10(P.Value), color = sig_class)) +
geom_point(alpha = 0.6, size = 1.3) +
scale_color_manual(values = c("Up in recurrence" = "#FF6E6E",
"Down in recurrence" = "#4FC3F7",
"Not significant" = "grey55")) +
geom_vline(xintercept = c(-1, 1), linetype = 2, color = "grey60") +
geom_hline(yintercept = -log10(0.05), linetype = 2, color = "grey60") +
geom_text_repel(data = lab, aes(label = symbol), size = 3, max.overlaps = 30,
box.padding = 0.4, segment.color = "grey70") +
labs(title = "火山图:术后复发 vs 无复发",
subtitle = sprintf("虚线为 |log2FC| = 1 与 P = 0.05(探索性筛选);FDR 校正后最小 FDR = %.3f,无显著探针集",
min(tt$adj.P.Val)),
x = expression(log[2]~"fold change (Recurrence / NoRecurrence)"),
y = expression(-log[10]~"(P value)"), color = NULL)
# 取探索性候选基因(|log2FC| > 1 中 P 值最小的若干探针集),按行(基因)做 z-score 标准化
top_n <- 50
hm_probe <- head(cand$probe_id, top_n)
if (length(hm_probe) < 10) hm_probe <- head(subset(tt, abs(logFC) > 1)$probe_id, top_n)
hm_mat <- expr_norm[hm_probe, , drop = FALSE]
hm_lab <- tt$symbol[match(hm_probe, tt$probe_id)]
rownames(hm_mat) <- ifelse(is.na(hm_lab), hm_probe, hm_lab)
hm_mat <- t(scale(t(hm_mat))) # 按基因标准化,突出组间相对高低
ann_col <- data.frame(Recurrence = pheno$recurrence,
ER = pheno$er,
PR = pheno$pr,
HER2 = pheno$her2,
Grade = pheno$grade,
row.names = pheno$gsm)
pheatmap(hm_mat,
color = colorRampPalette(rev(brewer.pal(11, "RdBu")))(100),
annotation_col = ann_col,
cluster_rows = TRUE, cluster_cols = TRUE,
show_colnames = FALSE, fontsize_row = 6.5, border_color = NA,
treeheight_row = 25, treeheight_col = 25,
main = sprintf("探索性候选差异基因(%d 个探针集;行为基因的 z-score)", length(hm_probe)))
top_genes <- head(unique(na.omit(cand$symbol)), 6)
if (length(top_genes) < 2) top_genes <- head(unique(na.omit(tt$symbol)), 6)
gb <- do.call(rbind, lapply(top_genes, function(g) {
if (!g %in% rownames(expr_gene)) return(NULL)
data.frame(symbol = g, expression = expr_gene[g, ], recurrence = pheno$recurrence)
}))
ggplot(gb, aes(recurrence, expression, fill = recurrence)) +
geom_boxplot(outlier.shape = NA, alpha = 0.7, width = 0.6) +
geom_jitter(width = 0.15, size = 1.6, alpha = 0.9) +
facet_wrap(~ symbol, scales = "free_y", nrow = 2) +
scale_fill_manual(values = c(NoRecurrence = "#4FC3F7", Recurrence = "#FF6E6E")) +
labs(title = "代表性差异基因的表达水平", x = NULL, fill = NULL,
y = expression(log[2]~"expression")) +
theme(legend.position = "bottom")
out_file <- file.path(res_dir, "GSE19697_limma_recurrence_vs_norecurrence.csv")
write.csv(tt, out_file, row.names = FALSE)
cat("差异表达结果已保存:", out_file, "\n")
差异表达结果已保存: results/GSE19697_limma_recurrence_vs_norecurrence.csv
out_hm <- file.path(res_dir, "GSE19697_top_diff_genes_expression.csv")
hm_expr <- expr_norm[hm_probe, , drop = FALSE]
rownames(hm_expr) <- rownames(hm_mat) # 行名用基因符号(无法注释的探针保留探针 ID)
write.csv(hm_expr, out_hm)
cat("热图所用基因的表达矩阵已保存:", out_hm, "\n")
热图所用基因的表达矩阵已保存: results/GSE19697_top_diff_genes_expression.csv
# 对照:MAS 5.0 值 + 分位数标准化 是否给出相似的 logFC?
if (cel_ok) {
expr_alt <- normalizeBetweenArrays(log2(pmax(expr_mas5, 1)), method = "quantile")
tt_alt <- topTable(eBayes(lmFit(expr_alt, design)),
coef = "Recurrence_vs_NoRecurrence", number = Inf, sort.by = "none")
common <- intersect(rownames(tt), rownames(tt_alt))
cmp <- data.frame(rma = tt$logFC[match(common, rownames(tt))],
mas5 = tt_alt$logFC[match(common, rownames(tt_alt))])
rho <- cor(cmp$rma, cmp$mas5, method = "spearman", use = "complete.obs")
cat(sprintf("RMA 与 MAS5+分位数标准化两种策略的 logFC(Spearman)相关系数:%.3f\n", rho))
cat(sprintf(" 两种策略下的候选基因(P<0.05 且 |logFC|>1)重叠比例:%.1f%%\n",
100 * mean(rownames(tt)[tt$P.Value < 0.05 & abs(tt$logFC) > 1] %in%
rownames(tt_alt)[tt_alt$P.Value < 0.05 & abs(tt_alt$logFC) > 1])))
print(ggplot(cmp, aes(rma, mas5)) +
geom_point(alpha = 0.15, size = 0.6) +
geom_abline(slope = 1, intercept = 0, color = "red", linetype = 2) +
labs(title = sprintf("两种标准化策略的 logFC 一致性(rho = %.3f)", rho),
x = "logFC (CEL + RMA)", y = "logFC (MAS5 + quantile)"))
} else {
cat("本次运行未使用 CEL 原始数据,跳过稳健性比较。\n")
}
RMA 与 MAS5+分位数标准化两种策略的 logFC(Spearman)相关系数:0.587
两种策略下的候选基因(P<0.05 且 |logFC|>1)重叠比例:64.1%
结果解读要点
recur = 1(术后复发)为实验组、recur = 0(无复发)为对照组,logFC > 0
表示该基因在复发组中表达更高。log2
转换、分位数标准化与探针集汇总,可直接送入 limma;而 series matrix 的
MAS 5.0 值是线性尺度,必须先 log2
再做分位数标准化,否则会出现大量假阳性。limma 的线性模型 +
经验贝叶斯方差收缩,能在 n = 24
的小样本下稳定估计方差,是芯片差异表达分析的标准做法。当前模型只含复发分组,未校正
ER/HER2/分级等混杂(该队列以 basal-like
三阴性为主,异质性相对有限)。|log2FC| > 1)。这类结果只能作为假设生成,需要在更大、更均衡的队列(如
TCGA、METABRIC)中独立验证,不能直接当作临床结论。eBayes(trend = TRUE)、或针对特定通路做基因集检验(如
limma::camera、roast),都比降低阈值更可靠。sessionInfo()
R version 4.6.1 (2026-06-24)
Platform: x86_64-conda-linux-gnu
Running under: Ubuntu 24.04.4 LTS
Matrix products: default
BLAS/LAPACK: /home/wangzg/miniconda3/envs/r-4.6/lib/libopenblasp-r0.3.34.so; LAPACK version 3.12.0
locale:
[1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
[3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
[5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
[7] LC_PAPER=en_US.UTF-8 LC_NAME=C
[9] LC_ADDRESS=C LC_TELEPHONE=C
[11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
time zone: Asia/Shanghai
tzcode source: system (glibc)
attached base packages:
[1] stats4 stats graphics grDevices utils datasets methods
[8] base
other attached packages:
[1] hgu133plus2cdf_2.18.0 matrixStats_1.5.0 RColorBrewer_1.1-3
[4] pheatmap_1.0.13 ggrepel_0.9.8 ggplot2_4.0.3
[7] hgu133plus2.db_3.13.0 org.Hs.eg.db_3.23.1 AnnotationDbi_1.74.0
[10] IRanges_2.46.0 S4Vectors_0.50.1 affy_1.90.0
[13] Biobase_2.72.0 BiocGenerics_0.58.1 generics_0.1.4
[16] limma_3.68.5
loaded via a namespace (and not attached):
[1] KEGGREST_1.52.2 gtable_0.3.6 xfun_0.60
[4] bslib_0.12.0 lattice_0.23-1 vctrs_0.7.3
[7] tools_4.6.1 tibble_3.3.1 RSQLite_3.53.3
[10] blob_1.3.0 pkgconfig_2.0.3 S7_0.2.2
[13] lifecycle_1.0.5 compiler_4.6.1 farver_2.1.2
[16] textshaping_1.0.5 Biostrings_2.80.1 statmod_1.5.2
[19] Seqinfo_1.2.0 htmltools_0.5.9 sass_0.4.10
[22] yaml_2.3.12 preprocessCore_1.74.0 pillar_1.11.1
[25] crayon_1.5.3 jquerylib_0.1.4 MASS_7.3-66
[28] thematic_0.1.8 cachem_1.1.0 tidyselect_1.2.1
[31] digest_0.6.39 dplyr_1.2.1 labeling_0.4.3
[34] fastmap_1.2.0 grid_4.6.1 cli_3.6.6
[37] magrittr_2.0.5 withr_3.0.3 rappdirs_0.3.4
[40] scales_1.4.0 bit64_4.8.4 rmarkdown_2.31
[43] XVector_0.52.0 httr_1.4.8 affyio_1.82.0
[46] bit_4.6.0 otel_0.2.0 ragg_1.5.2
[49] png_0.1-9 memoise_2.0.1 evaluate_1.0.5
[52] knitr_1.51 rlang_1.3.0 Rcpp_1.1.2
[55] glue_1.8.1 DBI_1.3.0 BiocManager_1.30.27
[58] rstudioapi_0.19.0 jsonlite_2.0.0 R6_2.6.1
[61] systemfonts_1.3.2