Bland–Altman方法一致性图

下载 R / Python 代码与数据(771 KB)

用两种测量方法的均值作为横轴、B−A 差值作为纵轴,显示平均偏倚和约 95% 一致性界限。适合展示配对测量的一致性,不用相关系数代替方法一致性。

图与代码

code/R/main.R
suppressPackageStartupMessages(library(ggplot2))
dir.create("outputs", showWarnings = FALSE)
agreement <- function(a, b) {
  stopifnot(length(a) == length(b), length(a) >= 2, all(is.finite(a)), all(is.finite(b)))
  delta <- b - a
  c(n_pairs = length(a), bias = mean(delta), sd_difference = sd(delta),
    loa_lower = mean(delta) - 1.96 * sd(delta), loa_upper = mean(delta) + 1.96 * sd(delta))
}
known <- agreement(c(10, 20, 30), c(9, 20, 31))
stopifnot(abs(known["bias"]) < 1e-12, abs(known["sd_difference"] - 1) < 1e-12,
          abs(known["loa_lower"] + 1.96) < 1e-12, abs(known["loa_upper"] - 1.96) < 1e-12)
d <- read.csv("data/paired_measurements.csv")
d$average <- (d$method_a + d$method_b) / 2
d$difference <- d$method_b - d$method_a
v <- agreement(d$method_a, d$method_b)
write.csv(data.frame(key = names(v), value = unname(v)), "outputs/stats_r.csv", row.names = FALSE)
write.csv(d[c("id", "average", "difference")], "outputs/points_r.csv", row.names = FALSE)
lines <- data.frame(value = v[c("loa_lower", "bias", "loa_upper")], type = c("LoA", "Bias", "LoA"))
p <- ggplot(d, aes(average, difference)) +
  geom_hline(data = lines, aes(yintercept = value, colour = type, linetype = type), linewidth = .7) +
  geom_point(shape = 21, fill = "#66C2A5", colour = "grey20", size = 2, alpha = .8, stroke = .3) +
  scale_colour_manual(values = c(Bias = "#D95F02", LoA = "grey35"), guide = "none") +
  scale_linetype_manual(values = c(Bias = "solid", LoA = "dashed"), guide = "none") +
  labs(x = "Mean of methods A and B (arbitrary units)", y = "Difference: B - A (arbitrary units)",
       subtitle = sprintf("Synthetic paired data: n = %d; bias = %.2f; LoA = %.2f to %.2f", v[1], v[2], v[4], v[5]),
       caption = "Solid: mean difference; dashed: bias +/- 1.96 SD (not confidence limits)") +
  theme_classic(base_size = 11) + theme(plot.subtitle = element_text(size = 8.5), plot.caption = element_text(size = 8))
ggsave("outputs/figure_r.png", p, width = 6, height = 4.5, dpi = 300, bg = "white")

# 账本从本次计算的统计表生成,显示精度与图中标注一致。
ledger_stats <- read.csv("outputs/stats_r.csv")
ledger_numbers <- lapply(seq_len(nrow(ledger_stats)), function(i) {
  key <- ledger_stats$key[i]
  value <- ledger_stats$value[i]
  list(id = key, value = value,
       display = if (startsWith(key, "n_")) sprintf("%.0f", value) else sprintf("%.2f", value),
       source = list(type = "computed", locator = "code/R/main.R -> outputs/stats_r.csv"))
})
jsonlite::write_json(list(schema = "zzy-media-studio/numbers/v1", asset = "GAL-0024", numbers = ledger_numbers),
                     "outputs/numbers.json", auto_unbox = TRUE, pretty = TRUE, digits = 16)

code/py/main.py
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import scienceplots

plt.style.use(["science", "no-latex"])
plt.rcParams["font.family"] = "sans-serif"
plt.rcParams["savefig.bbox"] = "standard"

def agreement(a, b):
    a, b = np.asarray(a), np.asarray(b)
    if len(a) != len(b) or len(a) < 2 or not np.isfinite(a).all() or not np.isfinite(b).all():
        raise ValueError("Need at least two finite pairs")
    difference = b - a
    bias, sd = difference.mean(), difference.std(ddof=1)
    return {"n_pairs": len(a), "bias": bias, "sd_difference": sd, "loa_lower": bias - 1.96*sd, "loa_upper": bias + 1.96*sd}


def main():
    Path("outputs").mkdir(exist_ok=True)
    d = pd.read_csv("data/paired_measurements.csv")
    v = agreement(d.method_a, d.method_b)
    average = (d.method_a + d.method_b)/2
    difference = d.method_b - d.method_a
    fig, ax = plt.subplots(figsize=(6, 4.5))
    fig.subplots_adjust(left=.15, right=.97, bottom=.18, top=.89)
    ax.scatter(average, difference, s=21, facecolor="#66C2A5", edgecolor=".2", linewidth=.4, alpha=.8)
    ax.axhline(v["bias"], color="#D95F02", lw=1.2)
    for key in ("loa_lower", "loa_upper"):
        ax.axhline(v[key], color=".35", ls="--", lw=1)
    ax.set(xlabel="Mean of methods A and B (arbitrary units)", ylabel="Difference: B - A (arbitrary units)")
    ax.minorticks_off()
    ax.set_title(f"Synthetic pairs: n = {v['n_pairs']}; bias = {v['bias']:.2f}; LoA = {v['loa_lower']:.2f} to {v['loa_upper']:.2f}", fontsize=8.5)
    fig.text(.5, .025, "Solid: mean difference; dashed: bias +/- 1.96 SD (not confidence limits)", ha="center", fontsize=7.5)
    pd.DataFrame(v.items(), columns=["key", "value"]).to_csv("outputs/stats_py.csv", index=False)
    pd.DataFrame({"id": d.id, "average": average, "difference": difference}).to_csv("outputs/points_py.csv", index=False)
    fig.savefig("outputs/figure_py.png", dpi=300, facecolor="white")
    plt.close(fig)


if __name__ == "__main__":
    main()

什么时候用

  • 同一对象由两种方法各测量一次,单位相同
  • 需要观察系统偏倚和差值随测量水平的变化

常见误用

  • 差值固定为 B−A,标准差为样本标准差(分母 n−1);一致性界限为偏倚±1.96SD
  • 两条一致性界限不是均值偏倚的置信区间;本例未估计界限本身的不确定性
  • 约95%的解释依赖差值近似正态、独立且方差大致恒定;异方差或重复测量需其他方法
  • 临床可接受差值必须事先确定,图中点大多在界限内不能证明两种方法可互换
  • 数据完全模拟,单位为任意单位,不作真实检测方法的评价

数据与下载

数据:固定种子 20261028 的 R 原创成对模拟测量(原创模拟数据,CC0-1.0(教学用途,非真实患者))。80 个模拟对象,各有 method_a 与 method_b 一次测量。潜在水平在 40~160 间均匀分布;两种方法叠加独立测量误差,B 另有预设 2.2 单位偏移。实际图内偏倚与界限由各语言代码从共享 CSV 独立计算,不直接使用生成参数代替估计。

下载 R / Python 代码与数据(771 KB)

下载包 sha256:2594aabafa59ab66b894c82c0f0788e849f884ae6b093e00422f25c3a8235c21