第3章 样本元数据与丰度表的关联及组织方式
拿到丰度表之后,你做的第一件事是什么?我猜很多人直接就开始跑差异分析了。且慢——没有元数据,你的丰度表就是个没有标签的盒子。样本来自哪个组?是处理还是对照?测序批次是什么?这些问题都藏在元数据里。
说白了,元数据就是样本的“身份证”和“档案”。丰度表记录的是每个样本里有哪些物种、各有多少条序列,而元数据描述的是这些样本本身:性别、年龄、时间点、处理方式……把两者关联起来,你才能问出有生物学意义的问题。
3.1 元数据长什么样
先看一个典型的元数据表格。它通常是一个表格,每一行是一个样本,每一列是一个变量。样本ID那一列是连接丰度表的桥梁。
| SampleID | Group | TimePoint | Batch | Sex |
|---|---|---|---|---|
| S01 | Control | Day0 | B1 | F |
| S02 | Control | Day0 | B1 | M |
| S03 | Treatment | Day1 | B2 | F |
| S04 | Treatment | Day1 | B2 | M |
| S05 | Control | Day7 | B1 | F |
而丰度表(OTU/ASV表)通常是这个格式——每一行是一个物种,每一列是一个样本。说白了就是矩阵转置一下:
| OTU_ID | S01 | S02 | S03 | S04 | S05 |
|---|---|---|---|---|---|
| OTU1 | 123 | 456 | 78 | 910 | 11 |
| OTU2 | 45 | 67 | 89 | 10 | 234 |
| OTU3 | 789 | 12 | 34 | 56 | 789 |
关键点:两个表格的 SampleID 列必须完全一致(大小写、有无空格都算不一致)。这是最容易踩坑的地方,后面我会细说。
3.2 为什么要关联?
你想想看,没有元数据你能做什么?只能看哪个物种多、哪个少——这跟看股票K线图不关心公司名称有什么区别?
- 要跑 PERMANOVA(adonis),需要分组信息作为自变量。
- 要画 PCA / PCoA 并着色,需要元数据里的分组列。
- 要做 差异丰度分析(DESeq2、edgeR、lefse),必须指定哪两组比。
- 要检查 批次效应,元数据里的Batch列是校正的输入。
我在项目中遇到过一件事:一个朋友跑完差异分析,把结果发给合作者,对方问“你这组是啥意思?”——他居然连元数据都没合并,标号全乱了。从那以后,我给所有分析流程加了一道强制检查。
3.3 关联的核心方法:基于SampleID合并
说白了就是用 样本ID作为主键,把元数据表的列“黏”到丰度表上。实际操作中,通常先整理元数据,再整理丰度表,然后做 inner_join 或 match。
我个人的习惯:先把元数据存为 metadata.csv,丰度表存为 abundance.csv,然后写一个R脚本或者Python脚本做合并。合并后的文件叫 merged.RData 或 merged.h5ad,之后所有下游分析都从这个文件开始。
在R中(推荐 phyloseq 结构)
library(phyloseq)
# 读入丰度表(OTU表)
otu_mat <- read.csv("abundance.csv", row.names = 1)
# 读入元数据
meta_df <- read.csv("metadata.csv", row.names = 1)
# 确保样本ID一致
stopifnot(all(rownames(meta_df) %in% colnames(otu_mat)))
# 构建 phyloseq 对象
OTU <- otu_table(otu_mat, taxa_are_rows = TRUE)
META <- sample_data(meta_df)
ps <- phyloseq(OTU, META)
# 保存
saveRDS(ps, "phyloseq_merged.rds")
在Python中(pandas + anndata)
import pandas as pd
import anndata as ad
# 读丰度表,样本为列
abun = pd.read_csv("abundance.csv", index_col=0)
# 转置为 obs × var 格式(anndata要求行是样本)
abun_t = abun.T
# 读元数据
meta = pd.read_csv("metadata.csv", index_col=0)
# 取交集样本
common = abun_t.index.intersection(meta.index)
abun_t = abun_t.loc[common]
meta = meta.loc[common]
# 构建AnnData
adata = ad.AnnData(X=abun_t.values,
obs=meta,
var=pd.DataFrame(index=abun_t.columns))
# 保存
adata.write("merged.h5ad")
我曾经遇到过一个坑:元数据里的SampleID前面有个看不见的 \ufeff(BOM头),跟丰度表的列名对不上。后来我发现,用 read.csv 默认带 fileEncoding="UTF-8-BOM" 才能读对。建议每次读完后用 trimws() 或者 .strip() 清理所有ID列。
3.4 组织方式:从宽表到对象
关联后的数据,常见有三种组织形式:
| 形式 | 说明 | 适用场景 |
|---|---|---|
| 宽表(wide format) | 每行是物种,每列是样本,再加上元数据列(比如在样本列前面加几列分组信息) | 快速查看,Excel操作 |
| 长表(long format) | 每行是一个样本-物种对,加上元数据列和丰度值 | ggplot2画图,线性模型 |
| 对象化(phyloseq / AnnData) | 丰度矩阵、元数据、分类信息打包成一个对象 | 专业分析,管道化 |
我个人偏爱第三种。因为做下游分析时,你不用反复去匹配ID,函数会自动处理。而且phyloseq或AnnData都自带了过滤、转换、可视化的方法,能省不少事。
3.5 常见问题与避坑指南
- 样本ID不完全匹配——经常出现元数据有30个样本,丰度表只有28个。这种情况我会先检查哪个样本丢了,然后跟实验人员确认是否确实要排除。
- 重复样本ID——某个样本在元数据里出现两次,或者丰度表里两个OTU名称一样。必须去重,否则合并后会报错或产生奇怪的行。
- 元数据数据类型错误——比如分组列本来是"Control" "Treatment",R读进来变成了因子,但有些函数需要字符型;或者时间点"Day0"被读成数值导致变成0。我会统一用
mutate_if或astype(str)处理字符型列。 - 缺失值处理——元数据里有的样本缺少某些信息。处理前需要决定是填充(比如用"Unknown")还是删除该样本。我一般会先做缺失值分布图,如果缺失比例小于5%,直接删除;大于5%就跟合作者商量。
一个小技巧:合并前先用 setdiff 或 .difference 打印出不在对方中的ID,然后追查原因。这个步骤我写成了函数,每跑一个新项目都先调用它。
3.6 知识体系框架
下面这张图展示了从原始丰度表和元数据到分析对象的完整流程。它可以帮助你理解每一步要做什么、数据在哪个环节变化。
3.7 实战建议:让流程可复现
我建议你从一开始就把元数据的整理写在脚本里,不要手动改。因为手动改一次就丢失一次可复现性。比如我会在R脚本里这样写:
# 读取原始元数据(可能来自Excel)
raw_meta <- readxl::read_excel("raw_data/ExperimentalDesign.xlsx", sheet=1)
# 标准化列名
colnames(raw_meta) <- make.names(colnames(raw_meta))
# 只保留需要的列
meta_clean <- raw_meta %>%
dplyr::select(SampleID, Group, TimePoint) %>%
dplyr::filter(!is.na(SampleID))
# 去重
meta_clean <- distinct(meta_clean, SampleID, .keep_all = TRUE)
# 写入标准化文件
write.csv(meta_clean, "metadata_clean.csv", row.names = FALSE)
注意看,我把每一步都写成代码,而不是在Excel里拖拽。这样下次拿到新数据,改一下路径就能跑。
我曾经犯过一个低级错误:在Excel里手动给元数据加了一列,保存成xlsx后,R读进来发现有些单元格被自动转成了日期格式(比如“Day0”变成了“00-1月-1900”)。从那以后,我坚决用CSV或者TSV,并且用 read.csv(..., colClasses="character") 强制把SampleID列读成字符串。
好了,这一章的内容就是这些。记住:元数据和丰度表的关联不是可选项,是所有下游分析的起点。花10分钟做对这一步,能为你后面省下10小时排错的时间。