☰
TCGA甲基化IDAT导入ChAMP:Sample Sheet构建全流程避坑指南
2026/10/24 5:05:32 网站建设 项目流程

说句实话,但凡自己拿ChAMP分析过TCGA甲基化数据的人,十有八九都栽在第一步。从GDC下载下来的压缩包一大堆,解压后全是.idat文件,好不容易装好了ChAMP包,高高兴兴跑champ.load(),结果不是报"Sample Sheet not found"就是"sentrix position mismatch",整晚耗在报错上,一条可用数据都没出来。

这篇文章不聊DMP、DMR那些下游分析,就专门解决一个问题:从TCGA下载的.idat原始文件出发,怎么构建一份ChAMP能认的Sample Sheet,成功把数据导进R里面。我把整个流程拆成几个关键环节,每一步的坑都会写出来。

1. 从TCGA下载到本地:IDAT文件的规范命名与目录重构

1.1 你手里到底拿到的是什么东西

TCGA的DNA甲基化数据主要来自Illumina的BeadChip平台,绝大多数样本是450K芯片,后期部分样本用了EPIC(850K)芯片。你从GDC下载原始数据时,最常见的文件格式是.idat,这是Illumina芯片扫描后直接输出的原始荧光信号文件。

每个样本对应两个.idat文件:

  • xxxx_R01C01_Grn.idat——绿色通道(Cy3)信号
  • xxxx_R01C01_Red.idat——红色通道(Cy5)信号

有些下载途径还会附带.bpm、.csv之类的注释文件,但ChAMP分析只需要Grn和Red两种.idat,这一点先心里有数。

文件名里最能说明问题的部分是_R01C01这样的后缀。R代表芯片上的row,C代表column,也就是说这个样本扫描时放在芯片的哪一格。芯片同一批可能有12个样本被放在不同格子,所以你会看到R01C01、R02C01、R03C01这样的编号,它们帮助软件把信号矩阵的坐标对上。

1.2 TCGA下载目录为什么不能直接用来跑champ.load

从GDC的Manifest下载文件后,你得到的目录结构大概长这样:

data/ ├── 00a1b2c3/ # 文件UUID命名的目录 │ ├── TCGA-XX-XXXX-01A-01D-XXXX-05_Grn.idat │ ├── TCGA-XX-XXXX-01A-01D-XXXX-05_Red.idat ├── 11d2e3f4/ │ ├── TCGA-YY-YYYY-11A-00D-YYYY-05_Grn.idat │ ├── TCGA-YY-YYYY-11A-00D-YYYY-05_Red.idat

问题在于:UUID目录把所有样本打散了,而且每个样本单独一个文件夹。ChAMP的champ.load()函数设计得非常固执——它只认一个扁平目录,也就是所有.idat文件必须在同一个文件夹里,Sample Sheet也只读取这个目录下的文件。

我第一次跑的时候,直接把GDC解压后的根目录指给champ.load,它返回的一堆报错里有一条关键信息:No .idat files found in given directory。明明文件就躺在子目录里,但它不递归查找。

所以第一步,先把所有.idat文件平铺到一个目录下。

1.3 扁平化目录的实操命令

在Windows下可以用PowerShell,Linux/macOS下用bash。Linux下的命令很简单:

find /path/to/tcga_data -name "*.idat" -exec cp {} /path/to/champ_input/ \;

这里用cp而不是mv,目的是保留原始下载结果,万一后面处理出问题还能回到初始状态。实际操作中,如果样本量很大(几百上千个文件),cp会稍慢,但磁盘空间一般够用,没必要纠结这一点慢。

有一点需要特别注意:如果同一个TCGA样本在GDC里既有Grn也有Red,复制后它们会出现在同一个目录下,文件名本身不会冲突。但如果不同批次的样本恰好重名,你需要先清理或者改名前缀。

我建议复制完以后跑一条检查命令,确认配对数:

ls /path/to/champ_input/*.idat | wc -l

如果正常,这个数字应该是偶数。不是偶数,说明有样本缺失了对应通道的信号文件,后面Sample Sheet再完善也导入不了。

2. Sample Sheet到底需要哪些列?字段来源与构建逻辑

2.1 先弄明白ChAMP读取文件的机制

ChAMP底层调用的是minfi的读取函数,但它在外面套了一层自己的逻辑。champ.load()查找Sample Sheet时,默认会在指定目录下找SampleSheet.csv,你也可以通过pdata参数直接传一个data.frame。

这个设计直接影响你的使用方式:

  • 走SampleSheet.csv路线:ChAMP会自动查找文件,但列名必须一个不差
  • 走pdata路线:更灵活,但你需要自己保证data.frame结构的正确性

我个人的习惯是走pdata路线,因为它可以少写一个CSV文件,也避免CSV在Excel里打开后再保存时编码格式被改坏的情况。

2.2 ChAMP实际需要哪些关键字段

champ.load()真正依赖的字段其实不多,新版ChAMP(特别是2.x以上)对列名的容忍度比早期版本宽松了一些,但仍然有几个核心字段必须存在:

字段名作用示例
sample_ID样本的唯一标识符,必须和IDAT文件名前缀完全一致TCGA-XX-XXXX-01A
sample_name显示名称,可以自定义Tumor_01
sentrix_id芯片编号,一个批次共用的芯片标识9384114098
sentrix_position芯片内的位置,即文件名中的R01C01部分R01C01
Group分组变量,用于后续差异分析Tumor/Normal

这里的核心难点是:sample_ID必须和IDAT文件名的前缀对得上。什么叫对得上?就是去掉_Grn.idat或_Red.idat、去掉_R01C01之后,剩下的部分完全一致。

比如文件名为:

TCGA-XX-XXXX-01A-01D-XXXX-05_R01C01_Grn.idat

那么:

  • sample_ID=TCGA-XX-XXXX-01A-01D-XXXX-05
  • sentrix_position=R01C01

有些人的sample_ID只写到样本barcode的前12位(如TCGA-XX-XXXX-01A),这会导致ChAMP在匹配文件时找不到对应的IDAT文件,因为文件名前缀还带着后面那截plate和well信息。

2.3 从TCGA元数据反推分组信息

分组信息一般不用手动填,从TCGA样本barcode规则就能解析出来。

一个标准的TCGA barcode长这样:

TCGA-XX-XXXX-01A-01D-XXXX-05

从第四段开始是样本类型编码,01是原发肿瘤,11是癌旁正常组织,02是转移肿瘤等等。你在构建Sample Sheet时,可以把这个编码映射成可读的Group标签:

barcode第四段含义Group建议
01原发肿瘤Tumor
02转移肿瘤Tumor
05复发肿瘤Tumor
11癌旁正常Normal
12正常对照Normal

如果下载的是GDC的公开数据,你通常还会拿到一个.tsv的metadata文件,里面已经标好了sample_type、sample_submitter_id这些字段。更稳妥的做法是直接拿这个metadata合并,而不是自己手动解析barcode字符串。除非你非常确定barcode格式没有变化,否则手动解析容易栽在少数特殊命名的样本上。

2.4 sentrix_id的来源

sentrix_id是芯片本身的批号,它决定了同一批扫描的样本可以共享一个芯片ID。这个信息你从TCGA的下载文件里不一定直接看得到,但可以从IDAT文件的内部信息中提取。

实践中最常见的方式是:先随便用几个样本跑一次minfi的read.metharray,或者直接看TCGA配套的.xlsx、.csv元数据文件。GDC有些项目导出数据时会带sentrix_id列,但不是所有项目都有。

如果实在没有,一个可行的折中办法是:先给所有样本填同一个占位符sentrix_id,champ.load()运行完成后检查结果是不是正常。这个方法在样本量不大、且你不需要做芯片间批次校正时问题不大,但如果后续要做champ.runCombat()之类的批次校正,占位符会让所有样本被当成同一批次处理,直接毁掉校正逻辑。

所以我强烈建议你把sentrix_id找出来。怎么找?写一个R脚本,读一下IDAT文件的实际信息:

library(illuminaio) idat_file <- list.files("path/to/idat", pattern = "_Grn.idat", full.names = TRUE)[1] data <- readIDAT(idat_file) # 芯片信息在data$ScanInfo或类似结构里 print(data$ScanInfo)

ScanInfo里通常能翻到芯片批次信息,虽然字段名称在不同版本软件里略有差异,但方向是对的。

3. champ.load导入的三个拦路虎:目录路径、芯片类型与分组信息的传递

3.1 目录路径的问题比你想的多

很多人写champ.load()的directory参数时,习惯写成:

champ.load(directory = "C:/Users/xxx/Desktop/TCGA甲基化数据/")

Windows下如果路径里带中文,或者路径结尾的反斜杠写成了\,都会出问题。ChAMP在底层拼接文件路径时用的是file.path(),在Windows下反斜杠会被当成转义字符处理,轻则路径错误,重则直接崩溃。

我的建议是:所有路径统一用正斜杠,不要用反斜杠;路径不要包含空格和中文;结尾不要加斜杠。

champ.load(directory = "C:/Users/xxx/Desktop/tcga_idat")

这一点看起来微不足道,但我帮人排查ChAMP导入问题的时候,十个里有三个是路径写法问题。

3.2 arraytype参数:450K还是EPIC,选错就全错

champ.load()有个参数叫arraytype,默认值是"450K",如果你的数据里混有EPIC芯片样本却不指定,ChAMP会按照450K的注释信息去解析,轻则很多探针位置对不上,重则直接报错。

判断方法很简单:看IDAT文件名前缀。如果是hMethyl450系列,大概率是450K;EPIC一般会带EPIC字样。但最稳妥的办法还是用官方工具看一下芯片类型,或者直接看下载时的元数据。

如果你不确定自己的数据混合了两种芯片,跑一下:

champ.load(directory = "path/to/idat", arraytype = "EPIC")

然后在导入结果里检查methylation矩阵的行数。450K的甲基化矩阵大约是48万个探针,EPIC大约是85万个探针。如果你设了EPIC但只出来48万行,说明数据里有450K文件混进来了,反过来也一样。

我个人的项目经验是:TCGA的数据以450K为主流,但近两年新上传的样本可能换成EPIC。如果GDC下载时分了批次,最好把同一次下载的文件手动检查一下,别指望自动识别。

3.3 pdata参数传对了,后面所有环节都顺

新版ChAMP对pdata参数的处理比老版本更严格,它要求传入一个data.frame,而且行数必须和IDAT样本数完全一致。

这里最常见的问题是:你构建pdata时用了全部样本,但目录里的IDAT文件只有部分样本;或者反过来,pdata写了15个样本,目录里有16个IDAT文件。ChAMP会怎么处理?它会按pdata的样本ID去匹配IDAT文件,匹配不上的会warning,但不会停止。

避免这类问题的最好办法是:以目录里的IDAT文件为准构造pdata,而不是以metadata里的查询列表为准。先扫描目录再回头构建pdata,顺序不能反。

构造pdata的代码模板我放到了第6节,那里直接用list.files()扫目录,能保证两者一致。

4. 导入之后的QuickQC:先别急着跑DMP,先看一眼数据质量

4.1 champ.load返回的对象到底长什么样

champ.load()成功运行后会返回一个列表,主要包括:

  • intensity:原始信号强度矩阵,行是探针,列是样本
  • methylation:经过预处理的Beta值矩阵,后续分析都用它
  • pd:你传入的pdata,ChAMP会对齐样本顺序后返回
  • QC:每个样本的质量控制统计信息

这个对象直接喂给champ.DMP()、champ.DMR()、champ.GSEA()这些下游函数都能用,所以第一步验收就看你拿到的methylation矩阵维度是否合理。

4.2 QC指标怎么读

ChAMP自带的QC信息里有几个关键指标,每个样本都要看:

  • Mean:所有探针信号强度的均值,过低的样本说明DNA质量差或杂交失败
  • Detection P value:检测p值,超过阈值的探针比例过高,说明样本整体信号不可靠

我习惯跑完导入后立刻画一次样本分布的散点图,看看有没有离群样本。如果有一个样本的平均信号强度比其他样本低一大截,趁早标记或者剔除,不要拖到下游分析才被发现。

一个小技巧:直接看qc里的阳性对照(bisulfite conversion controls)表现,这些对照探针在正常样本里信号极其稳定,一旦出现异常,往往意味着DNA处理过程出了问题。

4.3 Beta值分布是判断导入是否成功的试金石

一个正常的450K甲基化数据,Beta值分布应该呈现明显的双峰——一个峰贴近0(未甲基化),一个峰贴近1(完全甲基化),中间平平的过渡区域对应的是部分甲基化或拷贝数变异区域。

如果你导入后画出来的Beta值直方图是个单峰或者整个分布乱成一团,先别急着跑差异分析,大概率是上游出了问题:

  • 样本杂交失败或降解
  • 导入了错误的样本(比如把同一份样本重复导入了两次)
  • arraytype参数选错
  • 芯片扫描批次差异太严重

画的代码就这么几行:

hist(champ_result$methylation[, 1], breaks = 100, main = paste("Sample", colnames(champ_result$methylation)[1]), xlab = "Beta value")

我会打印前几个样本的分布图快速扫一眼,这个动作花不了30秒,却能避免后续分析推倒重来。

5. 一次真实失败案例:Sample Sheet少了一个下划线,全军覆没

5.1 报错现场还原

有一次我从一个分析流程的文档里复制了一段构建Sample Sheet的代码,跑完champ.load()后,屏幕上刷出一长串报错,关键信息是:

Error in match(x, table, nomatch = 0L) : 'sample_id' column not found in Sample Sheet samples in pdata but not found in IDAT files: ...

那批样本总共有48个,报错显示的匹配结果乱糟糟,一部分样本匹配不上IDAT文件。我第一反应是文件名出了问题,于是打开目录反复对比,文件名明明都对得上。

5.2 排查链路:从报错信息反推字段逻辑

后来我逐步检查pdata的结构,把colnames(pdata)打印出来,才发现列名是sample_id(全小写加下划线),但ChAMP内部查找的是sample_ID(ID两个字母大写)。R语言区分大小写,sample_id和sample_ID是两个完全不同的字符串。

问题就出在这里。那套分析流程是老版本ChAMP写的,当时对列名大小写宽容,pdata里只需要有sample_id就能工作;但升级新版ChAMP后,底层匹配逻辑改了,字段名必须严格按新规范来。

排查的完整链路是这样的:

  1. 打印colnames(pdata),发现列名不符合新版本要求
  2. 修改列名为sample_ID,重新跑champ.load()
  3. 又报错,这次是sentrix_position列不对
  4. 再打印,发现我把列名写成了sentrixposition,少了一个下划线
  5. 修正后,数据成功导入

5.3 这次踩坑带来的教训

不要在同一个地方栽两次。之后我再构建pdata,固定会做三件事:

  • 跑之前先print(colnames(pdata)),检查每个列名和ChAMP要求完全一致
  • 用str(pdata)看一下每列类型,特别是sample_ID必须是character而不是factor
  • 先用目录里挑两个样本跑一次champ.load(),确认无问题后再跑全量

最后一条我特别想说:全量导入48个样本失败一次,看似只是多花几分钟,但如果数据量大到几百个样本,调试成本完全不是一个数量级。小样本试跑永远是稳赚不亏的操作。

6. 自动化进阶:批量构建Sample Sheet的R脚本模板

6.1 为什么不建议手动填CSV

样本量小的时候手动建CSV没问题,但TCGA数据动辄上百个样本,手动填容易出现格式不一致、vs导入Excel被自动转换日期、分组名大小写不统一等问题。而且项目做多了你会发现,手动构建的Sample Sheet很难复用到下一个项目。

我后来把所有构建pdata的逻辑都写成了脚本,自上而下处理,整个流程只需要几秒。

6.2 一个可复用的完整R脚本

下面这个脚本是从我的实际项目中简化出来的,它做的事情是:

  • 扫描目录下所有IDAT文件
  • 解析文件名生成sample_ID、sentrix_position
  • 从TCGA barcode解析样本类型
  • 生成pdata并传入champ.load()
library(ChAMP) # 1. 扫描IDAT文件 idat_files <- list.files("path/to/idat", pattern = "_Grn.idat$", full.names = FALSE) red_files <- list.files("path/to/idat", pattern = "_Red.idat$", full.names = FALSE) stopifnot(length(idat_files) == length(red_files)) stopifnot(length(idat_files) > 0) # 2. 解析文件名:去掉 _Grn.idat 后按规则拆分 # 示例:TCGA-XX-XXXX-01A-01D-XXXX-05_R01C01_Grn.idat base_names <- sub("_Grn\\.idat$", "", idat_files) # 3. 提取 sentrix_position # 文件名里 "R01C01" 是最后一个下划线之后的部分 sentrix_pos <- sub(".*_", "", base_names) # 4. 提取 sample_ID(这里去掉 sentrix_position 部分) sample_id <- sub("_[^_]+$", "", base_names) # 5. 构建pdata数据框 pdata <- data.frame( sample_ID = sample_id, sample_name = sample_id, sentrix_position = sentrix_pos, stringsAsFactors = FALSE ) # 6. 从barcode解析分组 # 第4段:01肿瘤,11正常 parse_tcga_group <- function(barcode) { parts <- strsplit(barcode, "-", fixed = TRUE)[[1]] sample_code <- parts[4] if (sample_code == "01" || sample_code == "02" || sample_code == "05") { return("Tumor") } else if (sample_code == "11" || sample_code == "12") { return("Normal") } else { return("Other") } } pdata$Group <- sapply(pdata$sample_ID, parse_tcga_group) # 7. 检查分组分布,确保两类样本都存在 table(pdata$Group) # 8. 只能做预检,sentrix_id 如果有元数据就合并进来 # pdata$sentrix_id <- metadata$sentrix_id[match(pdata$sample_ID, metadata$sample_ID)] # 9. 试跑两个样本,确认没问题再全量跑 # test_pdata <- pdata[1:2, ] # champ.load(directory = "path/to/idat", pdata = test_pdata) # 10. 全量运行 champ_load_result <- champ.load( directory = "path/to/idat", pdata = pdata, arraytype = "450K", method = "ChAMP", autoFilter = TRUE, detPcut = 0.01 )

6.3 脚本使用时的注意事项

这个脚本有几个地方要根据实际项目调整:

  • 第7步检查分组分布特别重要,如果发现所有样本全是Tumor,没有Normal,下游差异分析就没法做对照,最好立刻回到数据源头确认是否有正常样本遗漏下载
  • arraytype要根据第3节的方法确认后填写,不要盲目用默认值
  • detPcut是检测p值阈值,默认0.01已经比较严格,如果你的数据整体质量一般,可以考虑放宽到0.05,但需在方法学描述里说明
  • autoFilter = TRUE会自动过滤掉检测失败的探针和样本,第一次导入建议开着,导入后再看QC

脚本里注释掉的那段sentrix_id合并逻辑,是在你有GDC元数据时用的。如果元数据里有sentrix_id列,把它合并进pdata即可:

pdata$sentrix_id <- metadata$sentrix_id[match(pdata$sample_ID, metadata$sample_ID)]

但合并前一定先检查metadata里有没有NA,如果有,这些样本会被ChAMP当作无芯片信息处理,需要补全后再跑。

6.4 验证pdata是否被正确识别

导入完成后,检查champ_load_result$pd的内容:

head(champ_load_result$pd) dim(champ_load_result$pd)

如果pd的行数和你的IDAT样本数一致,sample_ID和sentrix_position都一一对应正确,说明Sample Sheet构建这一步算真正通关了。到了这个时候,再往下跑champ.DMP()才有意义。

从零构建Sample Sheet这件事,核心就两条:一是让pdata和目录中的IDAT文件完全对齐,二是让列名严格匹配ChAMP的规范,一条都不能差。把这套脚本留好,以后拿到任何一批TCGA的.idat原始数据,都能在两分钟之内完成导入,不需要每次重复踩坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询