ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

UKB Nightingale NMR数据分析实战:从字段映射到GWAS的完整流程

UKB Nightingale NMR数据分析实战:从字段映射到GWAS的完整流程

简介:ukbnmr是一款专门面向R语言用户的生物信息学工具包,用于处理英国生物样本库(UK Biobank)中的Nightingale NMR代谢组学数据,解决原始数据集里生物标志物字段提取与比率计算的问题。核心功能由extract_biomarkers()和compute_nightingale_ratios()两个函数承载:前者能从下载的原始数据中提取NMR生物标志物并给出简洁列名,且支持基线与首次重复评估等多行测量记录;后者则基于168种可下载的生物标志物计算Nightingale比率,并附有nmr_info数据框方便查阅指标信息。资源压缩后仅18KB,包含19个文件,主要有R函数源码、Rd帮助文档、Markdown说明、DESCRIPTION配置等,结构紧凑,既能通过remotes直接安装,也便于本地加载研究。目前已有909人学习下载,特别适合熟悉R语言并从事UK Biobank代谢组学分析的研究人员参考学习。

1. 拿到一批UKB导出的Nightingale NMR数据,难的不是测量本身,而是读进来之后的那一步

ukbnmr这个R工具解决的是一个很具体、也很容易被低估的问题:UKB里Nightingale NMR生物标志物数据导出来是一张巨大的宽表,列名全是f.23456_0_0这种带数字的编码,单看列名你根本分不清哪一列是脂蛋白颗粒浓度、哪一列是氨基酸、哪一列是炎症标志物。ukbnmr把字段映射、样本访问次数筛选和单位整理自动化,让原本要手工折腾一两天的清洗工作变成几行代码的事。这篇笔记适合刚拿到UKB数据导出文件、准备做NMR代谢组学或遗传关联分析的R用户。我会按读取、清洗、质控、接下游分析的顺序拆开讲,中间包含具体代码和参数说明,最后记录几个我自己翻过车的地方。

2. 先搞清楚Nightingale数据在UKB里到底长什么样:字段区间与命名规则

2.1 导出文件里那一堆f.开头的列是哪儿来的

UKB的UKB数据导出不是按“一张表一个主题”给您的,而是把所有测量项拼成一张超宽表,每一行是一个样本,每一列是一个字段。Nightingale NMR的数据分布在以23400开头的字段区间里,导出来后列名形如f.23401_0_0、f.23402_0_0。这些字段并不是连续编号一一对应某个标志物,中间有跳号,也有语义分组,所以没有一份映射表的话,光靠肉眼无法判断哪列是谁。

我拿到导出的第一件事,是用data.table把表读进来,然后用grep把NMR相关列挑出来看数量。常见做法是这样:

library(data.table) raw <- fread("ukb_nmr_export.txt", sep = "\t", header = TRUE) # 把所有以 f.234 开头的列名筛出来 nmr_cols <- grep("^f\\.234", names(raw), value = TRUE) cat("NMR相关列数量:", length(nmr_cols), "\n") head(nmr_cols, 20)

这里fread是读取大宽表最快的路径,几百MB到几GB的文件都能扛住。grep("^f\\.234", ...)匹配以f.234开头的列名,注意正则里点号要转义。如果您的导出版本字段区间有变化,把前缀换成实际区间即可,比如f.235。这一步不依赖任何包,先确认数据规模,再决定后面怎么处理。

2.2 把idp编号拆开看:一个列名包含三层信息

ukbnmr内部把这些测量项统一叫idp,列名格式是idp_字段号_访问次数_重复索引。拆开看其实很好理解:

组成部分示例含义
前缀idp表示这是一个测量项标识符
字段号23401对应UKB导出里的原始字段编号
访问次数00=初始评估,1=第一次重复访问
重复索引0同一次访问内的测量重复编号,一般为0

这个命名规则非常关键,因为UKB里部分参与者不止一次来过采集中心。如果您的分析只应包含初始访问,就必须在筛选时把访问次数锁定为0;如果不锁,同一个人的两次测量会被当成两个独立样本,下游的关联分析就会有重复样本问题。这个我在第5章会专门讲。

另一个容易忽略的点是:idp_字段号与Nightingale的可读名称之间不是简单对应关系。比如某几个字段号都指向“高密度脂蛋白中的胆固醇”,只是亚类不同。ukbnmr包里内置了一张元数据表,记录每个字段号的短名称、全称、所属类别和单位,这比您自己去对照平台说明书要省很多事。

2.3 宽表不是给分析用的:从tidyverse视角看为什么要转置

UKB导出的宽表在存储上很合理,但对R里的统计建模来说却不好用。您做回归时通常需要“每行一个样本,每列一个变量”的形态;宽表是“每行一个样本,每列一个字段编号”,看似也是这个形态,问题出在列名不可读、列数太多、并且很多列之间高度相关。更麻烦的是,字段编号不只包含指标信息,还混着访问次数,直接拿宽表去merge临床表型时特别容易串列。

我一般会先把宽表转成“长格式”:每一行是样本×标志物的组合,然后按需再转回宽矩阵。ukbnmr的raw_to_nightingale函数本质上就是在做这个转换,同时把字段号映射成Nightingale可读名称、按访问次数筛选样本、把缺失编码统一处理好。下一章我会把整个调用过程展开。

3. ukbnmr核心工作流:从原始宽表到整洁的生物标志物矩阵

3.1 安装与数据读入:先说清楚依赖关系

ukbnmr是一个常规R包,安装方式与生态里其他包一致。建议用remotes安装,因为该包不一定在CRAN上,而且依赖data.table,直接装能省去手动处理依赖的麻烦:

# 在R会话里执行 install.packages("remotes") remotes::install_github("your_account/ukbnmr")

这里的your_account是占位符;实际安装时以您获取到的仓库地址为准。装好后加载,同时加载data.table:

library(ukbnmr) library(data.table)

数据读入这一步仍然用fread,原因很简单:UKB导出的文件常常有几个GB,read.csv会读得人想弃坑。注意fread会自动识别分隔符,但UKB的导出文件有时是空格分隔、有时是制表符分隔,稳妥起见在调用里明确指定sep。

读入之后不要急着分析,先做两个检查:一是列名里是否确实含有f.234,二是行数是否与您申请时拿到的样本数一致。不一致大概率是导出时勾选字段遗漏,后面映射会缺一片变量。

3.2 字段映射:raw_to_nightingale做了哪三件事

ukbnmr里最核心的函数是raw_to_nightingale,它接收原始宽表,返回一个包含多种对象的列表。这个函数实际做了三件事:把f.开头的列名转成idp命名,按访问次数过滤样本,把字段号映射为Nightingale标志物的可读名称。

# 只保留初始访问,生成处理后的对象 nmr_list <- raw_to_nightingale( raw = raw, visit = 0, idp_meta = ukbnmr::nmr_metadata ) # 看一眼返回对象里有什么 names(nmr_list)

参数visit = 0表示只保留初始评估数据。这在我的经验里几乎是必填项,因为默认行为不一定符合您的分析设计,明确写出来既防自己忘,也让脚本可读性更好。idp_meta是元数据表,一般用包内置的那份即可;如果您手里有更新版的UKB字段映射,也可以自己传入同格式的数据框。

为什么要有这一步而不是直接按列名筛?因为字段号和Nightingale名称之间并非一一对应。比如某些标志物在平台上有多个测量变体,字段号不同但名称近似;又比如同一标志物可能在不同访问次数下各有独立字段。raw_to_nightingale在内部处理这种一对多关系,把宽表整理成“每个标志物一列”的整洁矩阵,这是手工复制列名做不到的。

3.3 输出对象解读:samples、biomarkers、meta与flags

raw_to_nightingale返回的列表通常包含几个固定对象:样本信息、标志物矩阵、元数据和标记信息。我习惯一拿到就先看维度,确认没有样本丢失:

# 观察各对象的维度 dim(nmr_list$samples) # 样本信息 dim(nmr_list$biomarkers) # 标志物矩阵 # 查看元数据表前几行 head(nmr_list$meta)

samples里至少有eid和visit两列,eid是UKB的样本主键,后面所有表关联都用它。biomarkers则是一个行数等于样本数、列数等于标志物数的矩阵,列名已经是可读的Nightingale名称,比如VLDL_P_L这种短代码。meta记录每个标志物的短名称、全称、单位、所属类别(脂蛋白、脂肪酸、氨基酸、糖酵解代谢物、酮体、炎症标志物等)。

flags是包帮你做的越界标记,比如测量值超出平台线性范围、缺失、以及访问次数异常。这部分很多人不看,但它在质控里非常有用:直接用它把不合格样本筛掉,比拿原始值自己设阈值要可靠。

3.4 和dplyr管道一起用:筛选样本、拼接临床表型

整理好标志物矩阵后,下一步通常是和临床表型拼接。我一般先把samples转成data.table,再用merge关联协变量表:

library(dplyr) pheno <- fread("ukb_clinical.txt", sep = "\t") # 保留初始访问样本,关联年龄、性别等协变量 analysis_df <- nmr_list$samples %>% left_join(pheno, by = "eid") %>% filter(visit == 0) %>% filter(!is.na(age), !is.na(sex)) # 与标志物矩阵合并成建模用宽表 dat <- cbind(analysis_df, nmr_list$biomarkers)

这里left_join按eid关联,能自动检查两边样本是否对得上。filter(visit == 0)是双保险,因为前面转换时已经只保留初始访问,这里再筛一次并不会增加计算成本。拼好后的dat可以直接丢进lm、glm或者后续的GWAS工具。需要注意:如果您的表型文件里有些样本在NMR数据里不存在,left_join会留下NA行,需要在建模前再做一次缺失过滤。

4. QC与单位换算:Nightingale数据里最容易出错的隐蔽区

4.1 缺失编码:-1、-3和空值混在一起时的处理顺序

UKB导出的数据里,缺失不是R的NA,而是用特殊数字编码的:-1通常表示“不适用”,-3表示“数据缺失”,空值在fread读入后会变成NA。如果您不做处理直接拿去算均值、做回归,-1和-3会被当成真实测量值参与运算,结果会很离谱。

正确做法是先统一缺失编码,再做任何统计。我一般这样处理:

# 把常见的缺失编码统一替换为 NA biomarker_mat <- nmr_list$biomarkers biomarker_mat[biomarker_mat == -1 | biomarker_mat == -3] <- NA # 检查每个标志物的缺失率 missing_rate <- colMeans(is.na(biomarker_mat)) summary(missing_rate)

注意替换顺序:先替换-1和-3,再做缺失率统计。colMeans(is.na(...))可以快速算出每一列的缺失比例,如果某标志物缺失率超过20%,我会认真考虑是否纳入后续分析。这里不设固定阈值,因为不同分析对缺失容忍度不同,但至少心里要有数。

4.2 单位换算表:从平台原始单位到临床常用单位

Nightingale NMR平台输出的单位与常规临床检验单位经常不一致。最典型的是脂蛋白颗粒浓度、胆固醇、甘油三酯这类指标,平台常用mmol/L,但有些导出版本或某个字段会给出mg/dL;还有脂肪酸组成,平台给的是摩尔百分比,不是浓度。单位不统一,建模系数解读会出大问题。

标志物类别平台导出单位临床常用单位换算关系
胆固醇相关mmol/Lmg/dLmg/dL = mmol/L × 38.67
甘油三酯相关mmol/Lmg/dLmg/dL = mmol/L × 88.57
氨基酸mmol/Lμmol/Lμmol/L = mmol/L × 1000
脂肪酸组成mol%mol%无需换算,直接使用

我的习惯是:先不改数据,先把元数据表里每个标志物的单位列打印出来,确认哪些需要换,再批量处理。直接全部乘以一个系数是翻车重灾区,因为不同指标系数不同。

# 以 meta 为准,按单位类型批量转换 meta <- nmr_list$meta # 假设要对胆固醇类标志物换算 cholesterol_cols <- tolower(meta$short_name) target <- grep("chol", cholesterol_cols, value = TRUE) for (col in target) { biomarker_mat[, col] <- biomarker_mat[, col] * 38.67 }

这里grep("chol", ...)匹配短名称里带chol的标志物,把它们从mmol/L换算成mg/dL。实际使用时,我是先在meta里确认哪些标志物单位确实是mmol/L,再决定乘哪个系数。千万不要闭着眼睛全表乘。

4.3 样本级标记:极端值、重复样本与visit选择

单位搞定后,样本级的质控也很关键。Nightingale数据里有些标志物在极端情况下会超出定量范围,比如某些脂蛋白亚类在重度高脂血样本里可能测不准。ukbnmr的flags对象一般会把这些样本标记出来。我自己还会额外加一道IQR粗筛:

flag_outliers <- function(x) { q <- quantile(x, probs = c(0.25, 0.75), na.rm = TRUE) iqr_val <- q[2] - q[1] x < q[1] - 4 * iqr_val | x > q[2] + 4 * iqr_val } outlier_mat <- apply(biomarker_mat, 2, flag_outliers) row_outlier_count <- rowSums(outlier_mat, na.rm = TRUE) # 标记出超过20个标志物离群的样本 bad_samples <- names(which(row_outlier_count > 20)) bad_samples

4倍IQR这种阈值比较宽松,不会误杀太多正常样本,但能抓住明显的仪器异常或录入错误。rowSums(outlier_mat)把每个样本在所有标志物上的离群次数加起来,超过20就值得进一步查原始记录。这一步不是ukbnmr自带的,但我接每个项目都会多做这一道,因为Nightingale数据几十个指标相关性极强,如果某样本大量指标离群,那大概率不是生物学变异而是测量问题。

5. 处理UKB NMR数据时的五个典型翻车点与排查方法

5.1 现象:读进R后全是NA,一个真值都没有

这种情况我遇到过一次:用fread读档后,NMR相关列全是NA,但其他临床列正常。原因出在UKB导出文件的表头里,NMR字段列名带有特殊前缀或空格,fread自动识别时把列名弄脏了,导致列名匹配不上。

解决方法是读入后立刻打印names(raw)前30个,肉眼检查列名是否干净。如果列名带\ufeff之类的不可见字符,用gsub把特殊字符去掉。更稳妥的做法是在fread调用里加check.names = TRUE,让R自动清洗列名。

5.2 现象:同一行样本在最终矩阵里出现两次,分组回归结果对不上

这个问题非常隐蔽。原因是有人做过重复访问,如果您在raw_to_nightingale里没指定visit = 0,返回的samples会有同一个eid出现两次。后续关联分析时,同一个人的两条记录被当成两个独立样本,标准误被错误缩小。

解决方法是转换后立刻检查重复:any(duplicated(nmr_list$samples$eid)),如果有重复,回头把visit参数加上。另一个可能是您手动合并了两个版本的导出文件,比如一次下载了初始访问、另一次下载了重复访问,而您没在merge前去重。

5.3 现象:单位换算后某些标志物的分布出现双峰

分布双峰未必是错的,但如果是本来应该单峰的指标出现双峰,先别急着下结论,大概率是单位不统一。比如某个标志物部分样本以mmol/L为单位、部分样本以mg/dL为单位,合并后就会形成两个峰。

我一般先把该标志物按批次画密度图,看双峰是否与下载批次一致。如果一致,就是单位混了;如果双峰与批次无关,再考虑是否真实存在亚组差异。元数据表里单位列一定要逐项检查,不要只看前几行。

5.4 现象:字段编号对不上旧笔记,代码里硬编码失效

UKB的字段编号在历次数据版本中可能调整,旧笔记里写的f.23401到新版本里可能变成别的字段。我踩过的坑是:按照旧教程写的列名去筛,结果筛出来的列全空。

现在我的做法是永远不硬编码字段号,全部从nmr_list$meta里按标志物短名称取。代码里只出现Nightingale名称,不出现f.开头字段号,这样无论字段编号怎么变,脚本都能跑通。

5.5 现象:数据读入没问题,一跑全量建模就内存溢出

UKB宽表本身几个GB,读进来后R里的对象占用会翻几倍。如果这时候还同时保留原始宽表、转换后的列表、合并后的分析表,内存很容易爆。

我的习惯是:用fread读入原始文件后,完成raw_to_nightingale转换,立刻rm(raw)并调用gc()释放内存。后续只保留biomarker_mat和samples两个对象。如果还不够,就把biomarkers按类别拆开处理,先做脂蛋白类,再做其他类别,而不是一次性载入全部分析。

6. 进阶:把ukbnmr输出接进GWAS/PheWAS流程的最后一个关键动作

6.1 生成plink表型文件

NMR标志物做完QC后,下游最常见的用途是GWAS或PheWAS。这类工具一般要求表型文件是纯文本、每行一个样本、包含FID和IID列。ukbnmr给的是eid,直接写成两列FID=IID即可:

pheno_out <- data.frame( FID = nmr_list$samples$eid, IID = nmr_list$samples$eid, biomarker_mat ) write.table( pheno_out, file = "nmr_pheno.txt", sep = "\t", row.names = FALSE, quote = FALSE )

注意quote = FALSE必须写,否则plink会被引号搞乱。sep = "\t"也是惯例,空格的版本容易在标志物名称里混入空格时解析出错。

6.2 残差化之后再跑关联,而不是直接扔进去

很多NMR标志物受年龄、性别、BMI影响很大,直接拿原始值跑GWAS会出一堆假关联。我一般先做残差化:

dat$age <- as.numeric(dat$age) dat$sex <- as.factor(dat$sex) resid_mat <- apply(biomarker_mat, 2, function(y) { m <- lm(y ~ age + sex + BMI, data = dat, na.action = na.exclude) resid(m) }) out_pheno <- data.frame(FID = dat$eid, IID = dat$eid, resid_mat)

这里逐列回归并取残差,相当于把所有标志物统一调整到相同协变量水平。注意na.action = na.exclude保证残差向量长度与输入一致,否则样本错位很难查。这一步做完,再把残差表输给plink,出来的关联结果要干净得多。

从那以后,我每次拿到新的UKB导出,都会强制走一遍字段映射、访问次数筛选、单位核对、缺失编码替换、离群标记、残差化这一整套流程,中间任何一步偷懒,后面返工的成本都更高。希望这份拆解能帮您在处理NMR数据时少走几步弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进