5  Part 5:換你院內資料(含 PSM + ASMD)

預計時間:30 分鐘。 這一章是工作坊的真正重頭戲。

上半場你看到了:1329 個病人、24 個 stratum、漂亮的 paper 圖。

下半場你會看到:同樣的 pipeline、加上 PSM 處理院內 cohort 的非隨機分派、跑出可投稿的 Figure 1/2 草稿

📚 這章的方法論引用
  • PSM(propensity score matching):Austin 2011 Multivariate Behavioral Research1、Stuart 2010 Statistical Science2
  • ASMD(absolute standardized mean difference):Austin 2009 Statistics in Medicine3
  • MatchIt 套件:Ho et al. 2011 J Stat Softw4
  • 未測量混淆 sensitivity analysis(E-value):VanderWeele & Ding 2017 Ann Intern Med5、限制與誤用見 Ioannidis 20196
  • Target trial emulation 報告標準:Hansford 2023 JAMA Netw Open7、TARGET Statement 2025 JAMA8
  • 觀察性研究 adjusted analysis 解讀:Agoritsas 2017 JAMA Users’ Guide9
  • 真實世界 faricimab cohort 範例:Khanani 2024 TRUCKEE10

AI 對 PSM 細節(caliper、ratio、common support、unmeasured confounding 估計)常會給「看起來像、其實不對」的答案。請以教材的參考程式碼為準,並回查上面的 ref 自行驗證。


5.1 任務 21:載入「假裝是你院內」的資料

我們準備了一份 faricimab_my_hospital.csv,n = 180(faricimab 100 / aflibercept 80),region 全部 = Asia-Pacificschema 跟 trial 資料一模一樣。

📋 複製這段話,貼給 AI(🆕 開新對話):

請用 readr::read_csv()data/faricimab_my_hospital_baseline.csvdata/faricimab_my_hospital_followup.csv,看欄位是否跟 trial 資料一致。

回答之後,請等我看過教材的「參考程式碼」再執行——課堂上同學要跑同一份 code 結果才能對得上。

# ── 前置(可單獨執行):套件 + 資料 ──
library(readr); library(dplyr)
mh_baseline <- read_csv("data/faricimab_my_hospital_baseline.csv",
                        show_col_types = FALSE)
mh_followup <- read_csv("data/faricimab_my_hospital_followup.csv",
                        show_col_types = FALSE)

glimpse(mh_baseline)
Rows: 180
Columns: 12
$ patient_id    <chr> "PT-001", "PT-002", "PT-003", "PT-004", "PT-005", "PT-00…
$ arm           <chr> "faricimab", "faricimab", "faricimab", "aflibercept", "a…
$ study         <chr> "LUCERNE", "LUCERNE", "TENAYA", "TENAYA", "LUCERNE", "TE…
$ region        <chr> "Asia-Pacific", "Asia-Pacific", "Asia-Pacific", "Asia-Pa…
$ age           <dbl> 87, 71, 78, 65, 65, 80, 69, 68, 70, 70, 67, 91, 71, 83, …
$ sex           <chr> "F", "M", "F", "M", "F", "M", "F", "F", "M", "M", "F", "…
$ bcva_baseline <dbl> 72, 50, 54, 62, 53, 49, 63, 53, 51, 48, 71, 44, 53, 46, …
$ cst_baseline  <dbl> 308, 450, 214, 378, 451, 388, 413, 422, 230, 265, 558, 3…
$ irf_baseline  <dbl> 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0,…
$ srf_baseline  <dbl> 1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1,…
$ bcva_strat    <chr> "55-73", "<=54", "<=54", "55-73", "<=54", "<=54", "55-73…
$ lld_strat     <chr> ">=33", "<33", "<33", ">=33", ">=33", "<33", "<33", "<33…
glimpse(mh_followup)
Rows: 540
Columns: 6
$ patient_id <chr> "PT-001", "PT-001", "PT-001", "PT-002", "PT-002", "PT-002",…
$ week       <dbl> 4, 8, 12, 4, 8, 12, 4, 8, 12, 4, 8, 12, 4, 8, 12, 4, 8, 12,…
$ bcva       <dbl> 79, 84, 76, 53, 52, 56, 58, 52, 53, 80, 64, 71, 63, 67, 63,…
$ cst        <dbl> 150, 192, 153, 335, 278, 301, 105, 109, 82, 229, 180, 184, …
$ irf        <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ srf        <dbl> 1, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0,…
mh_baseline |> count(arm)
# A tibble: 2 × 2
  arm             n
  <chr>       <int>
1 aflibercept    80
2 faricimab     100

詳細欄位定義在 data/data_dictionary.md想做你自己的院內 cohort?把資料整成這個 schema、放進 data/、改下面的檔名就好。


5.1.1 為什麼院內資料必須先做 PSM?

院內 cohort 跟 RCT 最大的差別:沒有隨機分派。誰被打 faricimab、誰被打 aflibercept 是醫師根據病情、藥效預期、健保給付等決定,所以兩組的 baseline 通常不平衡(例如 faricimab 組可能本來就比較嚴重、年紀較大、CST 較厚)。

如果直接套 Part 2/3/4 的 code,跑出來的差異會混合「藥效」與「baseline 差距」,reviewer 第一輪退稿就會問:你怎麼處理 confounding?

方法 概念 何時用
Propensity score matching (PSM) 算每個病人「被分到 faricimab 的傾向分數」,然後 1:1 配對讓兩組分布相似 n 中等(100–500),interpret 容易,本書範例
Inverse probability of treatment weighting (IPTW) 每個病人加權,讓全 cohort 模擬隨機 n 大、想保留樣本數時

本書走 PSM 路線。完整背景見 Austin 2011 Multivariate Behavioral Research1 與 Stuart 2010 Stat Sci2

📖 名詞|propensity score / caliper / ratio
  • propensity score = 「給定 baseline 共變項,被分到 treatment 組的機率」,通常用 logistic regression 估計
  • caliper = 配對時兩人傾向分數差距的上限;常用 0.2 × SD(logit(propensity))
  • ratio = 配對比例(1:1、1:2、1:k);本書用 1:1 nearest neighbor

5.2 任務 22:診斷 baseline 不平衡(PSM 前的 ASMD)

📋 複製這段話,貼給 AI(↩︎️ 續前對話):

我有一個 R data frame mh_baseline,欄位 patient_id, arm (faricimab/aflibercept), age, sex, bcva_baseline, cst_baseline, irf_baseline, srf_baseline, bcva_strat, lld_strat。請用 cobalt::bal.tab() 算每個共變項的 absolute standardized mean difference (ASMD),threshold = 0.1,並幫我解讀哪些變項不平衡。

5.2.1 參考程式碼

# ── 前置(可單獨執行):套件 + 資料 ──
library(readr); library(dplyr); library(cobalt)
# treat / factor 一起在讀檔時建好,往後每段沿用同一份 mh_baseline
mh_baseline <- read_csv("data/faricimab_my_hospital_baseline.csv",
                        show_col_types = FALSE) |>
  mutate(
    treat = if_else(arm == "faricimab", 1L, 0L),
    sex   = factor(sex),
    bcva_strat = factor(bcva_strat),
    lld_strat  = factor(lld_strat)
  )

covariates <- c("age", "sex", "bcva_baseline", "cst_baseline",
                "irf_baseline", "srf_baseline", "bcva_strat", "lld_strat")

bal_pre <- bal.tab(
  reformulate(covariates, response = "treat"),
  data        = mh_baseline,
  estimand    = "ATT",
  thresholds  = c(m = 0.1),
  un          = TRUE
)
bal_pre
Balance Measures
                    Type Diff.Un     M.Threshold.Un
age              Contin.  0.2089 Not Balanced, >0.1
sex_M             Binary -0.0250     Balanced, <0.1
bcva_baseline    Contin. -0.0960     Balanced, <0.1
cst_baseline     Contin. -0.2135 Not Balanced, >0.1
irf_baseline      Binary -0.0925     Balanced, <0.1
srf_baseline      Binary -0.0500     Balanced, <0.1
bcva_strat_<=54   Binary  0.0800     Balanced, <0.1
bcva_strat_>=74   Binary -0.0500     Balanced, <0.1
bcva_strat_55-73  Binary -0.0300     Balanced, <0.1
lld_strat_>=33    Binary -0.0525     Balanced, <0.1

Balance tally for mean differences
                   count
Balanced, <0.1         8
Not Balanced, >0.1     2

Variable with the greatest mean difference
     Variable Diff.Un     M.Threshold.Un
 cst_baseline -0.2135 Not Balanced, >0.1

Sample sizes
    Control Treated
All      80     100
🎯 怎麼讀 ASMD
  • |ASMD| < 0.1 ✅ 平衡,不需要 adjust
  • 0.1 ≤ |ASMD| < 0.25 ⚠️ 輕微不平衡,建議 adjust
  • |ASMD| ≥ 0.25 ❌ 嚴重不平衡,必定要 PSM 或在 model 裡 adjust

模擬資料中你會看到部分變項 ASMD > 0.1——這是很典型的「院內病人 selection bias」。


5.3 任務 23:跑 1:1 nearest neighbor PSM

📋 複製這段話,貼給 AI(↩︎️ 續前對話):

請用 MatchIt::matchit() 做 1:1 nearest neighbor PSM,treatment 是 faricimab vs aflibercept,共變項 age, sex, bcva_baseline, cst_baseline, irf_baseline, srf_baseline, bcva_strat, lld_strat,distance 用 logistic regression、caliper = 0.2、ratio = 1。配對完拿 match.data()

5.3.1 參考程式碼

# ── 前置(可單獨執行):套件 + 資料 ──
library(readr); library(dplyr); library(MatchIt)
mh_baseline <- read_csv("data/faricimab_my_hospital_baseline.csv",
                        show_col_types = FALSE) |>
  mutate(treat = if_else(arm == "faricimab", 1L, 0L),
         sex = factor(sex), bcva_strat = factor(bcva_strat),
         lld_strat = factor(lld_strat))
mh_followup <- read_csv("data/faricimab_my_hospital_followup.csv",
                        show_col_types = FALSE)
set.seed(20260530)

m_out <- matchit(
  treat ~ age + sex + bcva_baseline + cst_baseline +
          irf_baseline + srf_baseline + bcva_strat + lld_strat,
  data     = mh_baseline,
  method   = "nearest",
  distance = "glm",
  ratio    = 1,
  caliper  = 0.2
)

summary(m_out)

Call:
matchit(formula = treat ~ age + sex + bcva_baseline + cst_baseline + 
    irf_baseline + srf_baseline + bcva_strat + lld_strat, data = mh_baseline, 
    method = "nearest", distance = "glm", caliper = 0.2, ratio = 1)

Summary of Balance for All Data:
                Means Treated Means Control Std. Mean Diff. Var. Ratio
distance                0.576        0.5300          0.4680     0.7912
age                    75.580       73.9750          0.2089     0.7186
sexF                    0.550        0.5250          0.0503          .
sexM                    0.450        0.4750         -0.0503          .
bcva_baseline          54.230       55.4750         -0.0960     0.7011
cst_baseline          387.690      410.6375         -0.2135     0.7499
irf_baseline            0.420        0.5125         -0.1874          .
srf_baseline            0.600        0.6500         -0.1021          .
bcva_strat<=54          0.580        0.5000          0.1621          .
bcva_strat>=74          0.100        0.1500         -0.1667          .
bcva_strat55-73         0.320        0.3500         -0.0643          .
lld_strat<33            0.740        0.6875          0.1197          .
lld_strat>=33           0.260        0.3125         -0.1197          .
                eCDF Mean eCDF Max
distance           0.1238   0.2550
age                0.0441   0.1200
sexF               0.0250   0.0250
sexM               0.0250   0.0250
bcva_baseline      0.0466   0.1650
cst_baseline       0.0478   0.1300
irf_baseline       0.0925   0.0925
srf_baseline       0.0500   0.0500
bcva_strat<=54     0.0800   0.0800
bcva_strat>=74     0.0500   0.0500
bcva_strat55-73    0.0300   0.0300
lld_strat<33       0.0525   0.0525
lld_strat>=33      0.0525   0.0525

Summary of Balance for Matched Data:
                Means Treated Means Control Std. Mean Diff. Var. Ratio
distance               0.5570        0.5494          0.0778     1.0447
age                   74.7429       74.3857          0.0465     0.7030
sexF                   0.5143        0.5143          0.0000          .
sexM                   0.4857        0.4857          0.0000          .
bcva_baseline         55.9571       55.9857         -0.0022     0.8180
cst_baseline         400.1857      395.2143          0.0462     0.9195
irf_baseline           0.4143        0.4714         -0.1158          .
srf_baseline           0.5857        0.6429         -0.1166          .
bcva_strat<=54         0.5000        0.5143         -0.0289          .
bcva_strat>=74         0.1286        0.1286          0.0000          .
bcva_strat55-73        0.3714        0.3571          0.0306          .
lld_strat<33           0.7429        0.6857          0.1303          .
lld_strat>=33          0.2571        0.3143         -0.1303          .
                eCDF Mean eCDF Max Std. Pair Dist.
distance           0.0285   0.1000          0.1003
age                0.0392   0.1000          0.9278
sexF               0.0000   0.0000          0.4857
sexM               0.0000   0.0000          0.4857
bcva_baseline      0.0266   0.1143          1.2496
cst_baseline       0.0296   0.0857          1.1235
irf_baseline       0.0571   0.0571          0.8104
srf_baseline       0.0571   0.0571          0.9915
bcva_strat<=54     0.0143   0.0143          1.0131
bcva_strat>=74     0.0000   0.0000          0.2000
bcva_strat55-73    0.0143   0.0143          0.8881
lld_strat<33       0.0571   0.0571          0.8468
lld_strat>=33      0.0571   0.0571          0.8468

Sample Sizes:
          Control Treated
All            80     100
Matched        70      70
Unmatched      10      30
Discarded       0       0
mh_matched <- match.data(m_out) |> as_tibble()

cat("配對後樣本數:", nrow(mh_matched),
    "(faricimab:", sum(mh_matched$treat == 1),
    "aflibercept:", sum(mh_matched$treat == 0), ")\n")
配對後樣本數: 140 (faricimab: 70 aflibercept: 70 )
mh_matched_followup <- mh_followup |>
  semi_join(mh_matched, by = "patient_id")
cat("配對後 follow-up rows:", nrow(mh_matched_followup), "\n")
配對後 follow-up rows: 420 
🎯 配對掉了多少人?

1:1 + caliper 0.2 通常會丟掉一些「找不到夠相似 control」的病人。丟越多 → bias 解得越乾淨、power 越弱。這是 PSM 永恆的 tradeoff。


5.4 任務 24:配對後重算 ASMD + Love plot

📋 複製這段話,貼給 AI(↩︎️ 續前對話):

請用 cobalt::bal.tab() 算配對後的 ASMD,並用 cobalt::love.plot() 畫前後對比的 Love plot(threshold 線 0.1)。

5.4.1 參考程式碼

# ── 前置(可單獨執行):套件 + 資料 ──
library(cobalt)
# ⚠️ 沿用前面任務的物件:m_out(請先跑完任務23,本段不重算)
bal_post <- bal.tab(m_out, thresholds = c(m = 0.1), un = TRUE)
bal_post
Balance Measures
                     Type Diff.Un Diff.Adj    M.Threshold
distance         Distance  0.4680   0.0778 Balanced, <0.1
age               Contin.  0.2089   0.0465 Balanced, <0.1
sex_M              Binary -0.0250   0.0000 Balanced, <0.1
bcva_baseline     Contin. -0.0960  -0.0022 Balanced, <0.1
cst_baseline      Contin. -0.2135   0.0462 Balanced, <0.1
irf_baseline       Binary -0.0925  -0.0571 Balanced, <0.1
srf_baseline       Binary -0.0500  -0.0571 Balanced, <0.1
bcva_strat_<=54    Binary  0.0800  -0.0143 Balanced, <0.1
bcva_strat_>=74    Binary -0.0500   0.0000 Balanced, <0.1
bcva_strat_55-73   Binary -0.0300   0.0143 Balanced, <0.1
lld_strat_>=33     Binary -0.0525  -0.0571 Balanced, <0.1

Balance tally for mean differences
                   count
Balanced, <0.1        11
Not Balanced, >0.1     0

Variable with the greatest mean difference
     Variable Diff.Adj    M.Threshold
 srf_baseline  -0.0571 Balanced, <0.1

Sample sizes
          Control Treated
All            80     100
Matched        70      70
Unmatched      10      30
love.plot(
  m_out,
  binary    = "std",
  threshold = 0.1,
  abs       = TRUE,
  var.order = "unadjusted",
  colors    = c("#C75D38", "#1F5C8B"),
  shapes    = c("circle filled", "triangle filled"),
  sample.names = c("配對前 (Unadjusted)", "配對後 (Matched)"),
  title     = "ASMD: Before vs After 1:1 PSM"
) +
  theme(legend.position = "bottom")
Figure 5.1: Love plot — PSM 前後 ASMD 對比;垂直線是 0.1 平衡門檻
🎯 配對成功的訊號
  1. 大部分變項 ASMD 從 > 0.1 掉到 < 0.1(紅圈 → 藍三角)
  2. 沒有任何變項配對後反而更不平衡
  3. 樣本縮水可接受(通常掉 10–30%)

5.5 任務 25:把 Part 2/3/4 的 pipeline 套到 matched cohort

⚠️ 5.2.2、5.2.3 用的是配對後資料

5.2.1 Table 1 用 unmatched cohort 顯示原本不平衡。 5.2.2 MMRM 與 5.2.3 CMH 一律用 matched cohort 跑——這是投稿 RWE 的標準呈現。

5.5.1 5.2.1 Table 1(unmatched — 顯示原始 cohort 的不平衡)

# ── 前置(可單獨執行):套件 + 資料 ──
library(readr); library(dplyr); library(gtsummary)
mh_baseline <- read_csv("data/faricimab_my_hospital_baseline.csv",
                        show_col_types = FALSE)
mh_baseline |>
  select(arm, age, sex, region, bcva_baseline, cst_baseline,
         irf_baseline, srf_baseline, study, bcva_strat, lld_strat) |>
  tbl_summary(
    by = arm,
    statistic = list(all_continuous() ~ "{mean} ({sd})",
                     all_categorical() ~ "{n} ({p}%)"),
    digits = list(all_continuous() ~ 1)
  ) |>
  add_overall() |>
  modify_caption("**Table 1 (My Hospital, unmatched).** Baseline characteristics, n=180")
Table 1 (My Hospital, unmatched). Baseline characteristics, n=180
Characteristic Overall
N = 1801
aflibercept
N = 801
faricimab
N = 1001
age 74.9 (8.3) 74.0 (9.1) 75.6 (7.7)
sex


    F 97 (54%) 42 (53%) 55 (55%)
    M 83 (46%) 38 (48%) 45 (45%)
region


    Asia-Pacific 180 (100%) 80 (100%) 100 (100%)
bcva_baseline 54.8 (14.1) 55.5 (15.5) 54.2 (13.0)
cst_baseline 397.9 (115.4) 410.6 (124.1) 387.7 (107.5)
irf_baseline 83 (46%) 41 (51%) 42 (42%)
srf_baseline 112 (62%) 52 (65%) 60 (60%)
study


    LUCERNE 90 (50%) 41 (51%) 49 (49%)
    TENAYA 90 (50%) 39 (49%) 51 (51%)
bcva_strat


    <=54 98 (54%) 40 (50%) 58 (58%)
    >=74 22 (12%) 12 (15%) 10 (10%)
    55-73 60 (33%) 28 (35%) 32 (32%)
lld_strat


    <33 129 (72%) 55 (69%) 74 (74%)
    >=33 51 (28%) 25 (31%) 26 (26%)
1 Mean (SD); n (%)

5.5.2 5.2.2 Figure 1(MMRM on matched cohort)

# ── 前置(可單獨執行):套件 + 資料 ──
library(dplyr); library(tidyr); library(mmrm); library(emmeans)
library(ggplot2); library(patchwork)
arm_colours <- c("faricimab" = "#1F5C8B", "aflibercept" = "#C75D38")
# ⚠️ 沿用前面任務的物件:mh_matched、mh_matched_followup(請先跑完任務23,本段不重算)
mh_fu_long <- mh_matched_followup |>
  left_join(
    mh_matched |> select(patient_id, arm, study, region,
                         bcva_baseline, cst_baseline,
                         bcva_strat, lld_strat),
    by = "patient_id"
  ) |>
  mutate(
    bcva_change = bcva - bcva_baseline,
    cst_change  = cst - cst_baseline,
    visit       = factor(week, levels = c(4, 8, 12)),
    arm         = factor(arm, levels = c("aflibercept", "faricimab")),
    bcva_strat  = factor(bcva_strat),
    lld_strat   = factor(lld_strat)
  )

m_bcva_mh <- tryCatch(
  mmrm(bcva_change ~ arm + visit + arm:visit + bcva_strat + lld_strat +
       us(visit | patient_id),
       data = mh_fu_long |> filter(!is.na(bcva_change))),
  error = function(e) {
    mmrm(bcva_change ~ arm + visit + arm:visit + bcva_strat + lld_strat +
         cs(visit | patient_id),
         data = mh_fu_long |> filter(!is.na(bcva_change)))
  })

m_cst_mh <- tryCatch(
  mmrm(cst_change ~ arm + visit + arm:visit + bcva_strat + lld_strat +
       us(visit | patient_id),
       data = mh_fu_long |> filter(!is.na(cst_change))),
  error = function(e) {
    mmrm(cst_change ~ arm + visit + arm:visit + bcva_strat + lld_strat +
         cs(visit | patient_id),
         data = mh_fu_long |> filter(!is.na(cst_change)))
  })

emm_b <- as.data.frame(emmeans(m_bcva_mh, ~ arm | visit)) |>
  mutate(week = as.numeric(as.character(visit)))
emm_c <- as.data.frame(emmeans(m_cst_mh, ~ arm | visit)) |>
  mutate(week = as.numeric(as.character(visit)))

baseline_zero <- function(df) {
  bind_rows(
    data.frame(arm = c("aflibercept", "faricimab"),
               week = 0, emmean = 0, lower.CL = 0, upper.CL = 0),
    df |> select(arm, week, emmean, lower.CL, upper.CL)
  ) |> arrange(arm, week)
}

p_b <- ggplot(baseline_zero(emm_b),
       aes(x = week, y = emmean, colour = arm, group = arm)) +
  geom_line(linewidth = 1) + geom_point(size = 3) +
  geom_errorbar(aes(ymin = lower.CL, ymax = upper.CL), width = 0.4) +
  scale_colour_manual(values = arm_colours, name = NULL) +
  scale_x_continuous(breaks = c(0, 4, 8, 12)) +
  labs(title = "(A) BCVA — My Hospital (post-PSM)",
       x = "Time (Weeks)", y = "Adjusted Mean BCVA Change\n(Letters)")

p_c <- ggplot(baseline_zero(emm_c),
       aes(x = week, y = emmean, colour = arm, group = arm)) +
  geom_line(linewidth = 1) + geom_point(size = 3) +
  geom_errorbar(aes(ymin = lower.CL, ymax = upper.CL), width = 0.4) +
  scale_colour_manual(values = arm_colours, name = NULL) +
  scale_x_continuous(breaks = c(0, 4, 8, 12)) +
  labs(title = "(B) CST — My Hospital (post-PSM)",
       x = "Time (Weeks)", y = "Adjusted Mean CST Change\n(μm)")

p_b + p_c + plot_layout(guides = "collect") &
  theme(legend.position = "bottom")

Figure 1 (My Hospital, post-PSM matched cohort) — MMRM on matched cohort
🎯 看出差別了嗎?
  • 走勢與 paper 一致:CST 兩 arm 都急速下降,faricimab 略多
  • CI 寬約 √(1329/n_matched) 倍
  • PSM 後估計值是「近似 ATT」(average treatment effect on the treated),論文要寫清楚

5.5.3 5.2.3 Figure 2(CMH on matched cohort)

# ── 前置(可單獨執行):套件 + 資料 ──
library(dplyr); library(tidyr); library(ggplot2); library(patchwork)
arm_colours <- c("faricimab" = "#1F5C8B", "aflibercept" = "#C75D38")
# ⚠️ 沿用前面任務的物件:mh_matched、mh_matched_followup(請先跑完任務23,本段不重算)
cmh_weighted_proportion <- function(data, outcome_col,
                                    arm_col = "arm",
                                    strata_cols = c("bcva_strat", "lld_strat")) {
  d <- data |>
    filter(!is.na(.data[[outcome_col]])) |>
    mutate(stratum = do.call(paste, c(across(all_of(strata_cols)), sep = "|")))

  per_stratum <- d |>
    group_by(stratum, !!sym(arm_col)) |>
    summarise(n = n(), x = sum(.data[[outcome_col]]), p = x / n, .groups = "drop")

  arms <- levels(factor(per_stratum[[arm_col]]))
  ws <- per_stratum |>
    select(stratum, !!sym(arm_col), n) |>
    pivot_wider(names_from = !!sym(arm_col), values_from = n, values_fill = 0) |>
    mutate(w = (.data[[arms[1]]] * .data[[arms[2]]]) /
                pmax(.data[[arms[1]]] + .data[[arms[2]]], 1)) |>
    filter(.data[[arms[1]]] > 0 & .data[[arms[2]]] > 0) |>
    select(stratum, w)

  per_stratum |>
    inner_join(ws, by = "stratum") |>
    group_by(!!sym(arm_col)) |>
    summarise(
      p_hat = sum(w * p) / sum(w),
      se    = sqrt(sum(w^2 * p * (1 - p) / pmax(n, 1))) / sum(w),
      .groups = "drop"
    ) |>
    mutate(lower = pmax(p_hat - 1.96 * se, 0),
           upper = pmin(p_hat + 1.96 * se, 1))
}

mh_fu_strata <- mh_matched_followup |>
  left_join(
    mh_matched |> select(patient_id, arm, bcva_strat, lld_strat,
                         irf_baseline, srf_baseline),
    by = "patient_id"
  ) |>
  mutate(
    abs_irf  = if_else(is.na(irf), NA, as.integer(irf == 0)),
    abs_srf  = if_else(is.na(srf), NA, as.integer(srf == 0)),
    abs_both = if_else(is.na(irf) | is.na(srf), NA,
                       as.integer(irf == 0 & srf == 0)),
    arm = factor(arm, levels = c("aflibercept", "faricimab"))
  )

mh_baseline_abs <- mh_matched |>
  mutate(
    week = 0L,
    abs_irf  = as.integer(irf_baseline == 0),
    abs_srf  = as.integer(srf_baseline == 0),
    abs_both = as.integer(irf_baseline == 0 & srf_baseline == 0),
    arm = factor(arm, levels = c("aflibercept", "faricimab"))
  ) |>
  select(patient_id, arm, bcva_strat, lld_strat,
         week, abs_irf, abs_srf, abs_both)

mh_fu_strata <- bind_rows(
  mh_baseline_abs,
  mh_fu_strata |> select(patient_id, arm, bcva_strat, lld_strat,
                         week, abs_irf, abs_srf, abs_both)
)

mh_results <- expand.grid(
  outcome = c("abs_irf", "abs_srf", "abs_both"),
  wk      = c(0, 4, 8, 12),
  stringsAsFactors = FALSE
) |>
  rowwise() |>
  mutate(res = list(
    cmh_weighted_proportion(
      mh_fu_strata |> filter(week == wk),
      outcome_col = outcome
    )
  )) |>
  ungroup() |>
  tidyr::unnest(res) |>
  rename(week = wk)

make_mh_panel <- function(out_col, ttl) {
  dat <- mh_results |> filter(outcome == out_col)
  ggplot(dat, aes(x = factor(week), y = p_hat * 100, fill = arm)) +
    geom_col(position = position_dodge(width = 0.8),
             width = 0.7, colour = "white") +
    geom_errorbar(aes(ymin = lower * 100, ymax = upper * 100),
                  position = position_dodge(width = 0.8), width = 0.25) +
    scale_fill_manual(values = arm_colours, name = NULL) +
    scale_y_continuous(limits = c(0, 100),
                       expand = expansion(mult = c(0, 0.08))) +
    labs(title = ttl, x = "Visit (Week)", y = "Proportion (%)") +
    theme(legend.position = "bottom")
}

(make_mh_panel("abs_both", "(A) IRF and SRF") |
   make_mh_panel("abs_irf", "(B) IRF") |
   make_mh_panel("abs_srf", "(C) SRF")) +
  plot_layout(guides = "collect") &
  theme(legend.position = "bottom")

Figure 2 (My Hospital, post-PSM) — CMH-weighted absence proportions on matched cohort

5.6 任務 26:討論 — 你院內資料的 paper 該怎麼寫?

📋 複製這段話,貼給 AI(🆕 開新對話):

我用 n=180 的院內 cohort、配對後做 1:1 PSM 跑出和 TENAYA/LUCERNE 同方向的結果,但 95% CI 寬很多。如果我要把這個結果投 Ophthalmology 這類期刊,我應該:(1)強調什麼?(2)reviewer 會質疑什麼?(3)下一步該怎麼處理 limitation?請給我 3 段建議文字。

(提醒:AI 給的論文寫法會幻想 reviewer 意見,請對照真實期刊的最新 RWE methodological standard 自行驗證。)

期刊 reviewer 常見質疑(對 PSM 後 RWE)
  1. Power 不足 → 解法:聯院 / 多中心、或定位成 hypothesis-generating
  2. 單一族群(Asia-Pacific) → 解法:明確說 generalizability 限於該族群
  3. PSM 把哪些人排除掉了? → 解法:附 unmatched vs matched ASMD 對比表(Love plot)、報告 effective sample size3
  4. 未測量混淆(unmeasured confounding) → 解法:sensitivity analysis(E-value5,6、negative control outcome)
  5. 觀察期短(12 週) → 解法:拉長到 48 / 60 / 108 週,引用 paper 的 long-term data 對比
  6. 是不是隨便找 cohort? → 解法:用 target trial emulation framework 描述設計、follow TARGET / Hansford 2023 報告 checklist7,8

5.7 任務 27:你回院後的 checklist

5.7.1 Step 1:找院內 IT 撈 cohort

「我要找從 2024-01-01 起、診斷有 H35.32(nAMD)、且至少打過一次 faricimab (院內代碼 = ??) 或 aflibercept (院內代碼 = ??) 的病人。每個病人需要:

  • 年齡、性別、區域(如果跨院)
  • index 日期(首次注射日)
  • index 日後 4/8/12 週的 BCVA、OCT CST
  • index 日後 4/8/12 週的 OCT 報告(IRF/SRF presence)
  • baseline 共變項:合併症、過去打過幾針、是否雙眼…(PSM 共變項清單越完整越好)」

5.7.2 Step 2:IRB 申請

  • 一般院內 retrospective chart review,預期可走 expedited review
  • 把這份 csv schema 與本書 Quarto 連結貼進 protocol,reviewer 看就懂

5.7.3 Step 3:把 csv 放進 data/、改本書 _quarto.yml 的 title → render

quarto::quarto_render()

完成後 _book/ 裡就是「用你院內資料做出的論文 draft」。

5.7.4 Step 4:拿給 Shao 看(眨眼)


本章重點
  1. 院內 RWE 沒有隨機分派 → 必須先做 PSM 或 IPTW,否則 reviewer 退稿
  2. PSM 三步驟:算 propensity score → caliper-restricted nearest neighbor matching → 重算 ASMD 確認 < 0.1
  3. 5.2.2(MMRM)和 5.2.3(CMH)使用 matched cohort,Table 1 同時呈現 unmatched 與 matched
  4. PSM 後估計的是 ATT(不是 RCT 的 ATE),論文要寫清楚
  5. 同一份 pipeline + 換 csv 路徑就能跑,但 Power 與 generalizability 限制要誠實寫
1.
Austin PC. An introduction to propensity score methods for reducing the effects of confounding in observational studies. Multivariate Behavioral Research. 2011;46(3):399-424. doi:10.1080/00273171.2011.568786
2.
Stuart EA. Matching methods for causal inference: A review and a look forward. Statistical Science. 2010;25(1):1-21. doi:10.1214/09-STS313
3.
Austin PC. Balance diagnostics for comparing the distribution of baseline covariates between treatment groups in propensity-score matched samples. Statistics in Medicine. 2009;28(25):3083-3107. doi:10.1002/sim.3697
4.
Ho DE, Imai K, King G, Stuart EA. MatchIt: Nonparametric preprocessing for parametric causal inference. Journal of Statistical Software. 2011;42(8):1-28. doi:10.18637/jss.v042.i08
5.
VanderWeele TJ, Ding P. Sensitivity analysis in observational research: Introducing the E-Value. Annals of Internal Medicine. 2017;167(4):268-274. doi:10.7326/M16-2607
6.
Ioannidis JPA, Tan YJ, Blum MR. Limitations and misinterpretations of E-Values for sensitivity analyses of observational studies. Annals of Internal Medicine. 2019;170(2):108-111. doi:10.7326/M18-2159
7.
Hansford HJ, Cashin AG, Jones MD, Swanson SA, Islam N, et al. Reporting of observational studies explicitly aiming to emulate randomized trials: A systematic review. JAMA Network Open. 2023;6(9):e2336023. doi:10.1001/jamanetworkopen.2023.36023
8.
Cashin AG, Hansford HJ, Hernán MA, et al. Transparent reporting of observational studies emulating a target trial—the TARGET statement. JAMA. 2025;334(12):1084-1093. doi:10.1001/jama.2025.13350
9.
Agoritsas T, Merglen A, Shah ND, O’Donnell M, Guyatt GH. Adjusted analyses in studies addressing therapy and harm: Users’ guides to the medical literature. JAMA. 2017;317(7):748-759. doi:10.1001/jama.2016.20029
10.
Khanani AM, Aziz AA, Khan H, et al. The real-world efficacy and safety of faricimab in neovascular age-related macular degeneration: The TRUCKEE study. Eye. 2024;38(17):3338-3344. doi:10.1038/s41433-024-03002-7