Getting started with respondeR

library(respondeR)

The idea

Trials of continuous outcomes usually report a mean change and standard deviation in each arm. Those are hard to communicate: a standardized mean difference of 0.3 means little to a patient. A responder analysis translates the continuous result into something concrete: the proportion of patients who improve by at least a minimal important difference (MID). It contrasts the arms on familiar scales: risk difference, risk ratio, odds ratio, number needed to treat.

respondeR does this from summary statistics alone, using the cut-point approach of Anzures-Cabrera, Sarpatwari & Higgins (2011). It never needs individual patient data.

Data format

One row per study (or per comparison). The experimental arm columns end in _e, the control arm in _c:

sample_responder_data
#>     study  change_e     sd_e n_e     change_c     sd_c n_c
#> 1 Study 1 0.9581395 1.257593  43  0.217777778 1.195501  45
#> 2 Study 2 0.7920863 1.281364 139  0.003448276 1.324629 145
#> 3 Study 3 1.0230769 1.341201 156 -0.041975309 1.263178 162
Column Meaning
study Study (comparison) label
change_e, change_c Mean change per arm
sd_e, sd_c SD of change per arm
n_e, n_c Sample size per arm
trial (optional) Trial label, shared by the comparisons of one multi-arm trial

If a trial reports baseline and final SDs instead of the SD of the change, reconstruct it with impute_change_sd() (and vary the assumed correlation as a sensitivity analysis):

impute_change_sd("prepost", sd_baseline = 2.1, sd_final = 2.4, r = 0.5)
#> [1] 2.26495

A first analysis

Suppose a change above 1 is clinically meaningful. With the default settings, responder_analysis() returns one row per pooling method, printed as a compact table with the number needed to treat:

res <- responder_analysis(sample_responder_data, mid = 1)
res
#> Responder analysis (respondeR)
#>   MID = 1 (a higher change is a response); k = 3 studies; fixed
#>   effects; delta per-study variance
#>   Percentages; 95% confidence intervals.
#> 
#>           Method   PE   PC                  RD                  RR
#>       Individual    -    - 25.5 (20.0 to 31.0) 2.15 (1.79 to 2.58)
#>    Weighted mean 47.6 22.1 25.5 (20.0 to 31.1) 2.16 (1.80 to 2.59)
#>  Unweighted mean 47.7 22.8                24.9                2.09
#>           Median 48.7 21.5                27.2                2.26
#>                   OR                   NNT
#>  3.20 (2.45 to 4.18) NNTB 3.9 (3.2 to 5.0)
#>  3.21 (2.46 to 4.19) NNTB 3.9 (3.2 to 5.0)
#>                 3.09              NNTB 4.0
#>                 3.46              NNTB 3.7
#> 
#> Use as.data.frame() for every column, summary() for details, plot() for a forest plot.

The result is still a data frame, so columns are available directly, and as.data.frame() gives every column, including the heterogeneity statistics:

res$rd
#> [1] 0.2548985 0.2551921 0.2487438 0.2718912
head(as.data.frame(res), 2)
#>       method pooling k       p_e       p_c        rd     rd_lb     rd_ub
#> 1 individual   fixed 3        NA        NA 0.2548985 0.1997582 0.3100388
#> 2   weighted   fixed 3 0.4757294 0.2205372 0.2551921 0.1998579 0.3105264
#>         rr    rr_lb    rr_ub       or    or_lb    or_ub      nnt   nnt_lb
#> 1 2.148360 1.787463 2.582123 3.197922 2.447367 4.178656 3.923130 3.225403
#> 2 2.157138 1.795354 2.591827 3.207140 2.455182 4.189402 3.918616 3.220338
#>     nnt_ub       var_rd         tau2        i2        q     q_p pi_lb pi_ub
#> 1 5.006053 0.0007914838 0.0006313298 0.1925461 2.476921 0.28983    NA    NA
#> 2 5.003555 0.0007970618           NA        NA       NA      NA    NA    NA

summary(res) adds the settings and the per-study weights.

Which way is “better”?

By default a higher change is a response. For outcomes where lower is better (pain, symptom scores), set direction = "lower" and give the MID as the required change (for example mid = -1.5 for a 1.5-point reduction).

Per-study results and a forest plot

responder_rd_individual() returns the per-study risk differences, and plot() draws them with the pooled estimates:

responder_rd_individual(sample_responder_data, mid = 1)
#>     study       p_e       p_c        rd         se      ci_lb     ci_ub
#> 1 Study 1 0.4867232 0.2564577 0.2302655 0.08067806 0.07213941 0.3883916
#> 2 Study 2 0.4355507 0.2259278 0.2096229 0.04394149 0.12349918 0.2957466
#> 3 Study 3 0.5068639 0.2047187 0.3021452 0.04108392 0.22162219 0.3826682
plot(res)

Forest plot of per-study responder risk differences with pooled estimates

Random effects and heterogeneity

When studies disagree, use random effects. With few studies, prefer the Hartung-Knapp-Sidik-Jonkman interval with its ad hoc floor (ci_method = "hksj_adhoc"), which is never narrower than the Wald interval:

responder_analysis(sample_responder_data, mid = 1, method = "individual",
                   pooling = "random", ci_method = "hksj_adhoc")
#> Responder analysis (respondeR)
#>   MID = 1 (a higher change is a response); k = 3 studies; random
#>   effects; delta per-study variance
#>   Percentages; 95% confidence intervals.
#> 
#>      Method PE PC                  RD                  RR                  OR
#>  Individual  -  - 25.4 (11.5 to 39.3) 2.15 (1.43 to 3.22) 3.19 (1.72 to 5.91)
#>                    NNT
#>  NNTB 3.9 (2.5 to 8.7)
#> 
#>   individual: I-squared 19%, tau-squared 0.000631, Q 2.48 (p 0.290); prediction interval -26.5 to 77.4
#> 
#> Use as.data.frame() for every column, summary() for details, plot() for a forest plot.

How much does the MID matter?

responder_curve() repeats the analysis across thresholds. Report the curve, not just one value; open circles mark thresholds where a single study would hold most of the weight.

curve <- responder_curve(sample_responder_data, mids = seq(0, 2, by = 0.25))
plot(curve, at = 1)

Pooled risk difference against the MID threshold with pointwise confidence bands

Summary-of-findings rows

responder_sof() turns the pooled relative effect into absolute effects per 1000 patients at an assumed control risk, by default the median control-arm responder risk, in GRADE wording. Give several control risks for low, moderate and high baseline risk:

ind <- responder_analysis(sample_responder_data, mid = 1, method = "individual")
responder_sof(ind)
#> Summary of findings (responders)
#> 
#>      Method Control risk         Intervention risk
#>  individual 226 per 1000 483 per 1000 (417 to 549)
#>                                Difference        Relative effect
#>  257 more per 1000 (191 more to 324 more) OR 3.20 (2.45 to 4.18)
responder_sof(ind, control_risk = c(0.1, 0.4))
#> Summary of findings (responders)
#> 
#>      Method Control risk         Intervention risk
#>  individual 100 per 1000 262 per 1000 (214 to 317)
#>  individual 400 per 1000 681 per 1000 (620 to 736)
#>                                Difference        Relative effect
#>  162 more per 1000 (114 more to 217 more) OR 3.20 (2.45 to 4.18)
#>  281 more per 1000 (220 more to 336 more) OR 3.20 (2.45 to 4.18)

A threshold-free alternative

Choosing a MID is sometimes contentious. The common-language effect size sidesteps it: the probability that a randomly chosen treated patient has a better change than a randomly chosen control.

cles <- responder_cles(sample_responder_data)
sprintf("CLES = %.1f%% (%.1f%% to %.1f%%)",
        100 * cles$cles, 100 * cles$cles_lb, 100 * cles$cles_ub)
#> [1] "CLES = 69.0% (65.1% to 72.7%)"

A real example: VAS pain after exercise therapy

The package bundles a real dataset, vas_pain: the 20 exercise-versus-control comparisons pooled for the visual analogue scale (VAS) pain outcome by Li, Bao, Wang and Zhao (2025). Change scores are post minus baseline VAS on a 0 to 10 cm scale, so a more negative value is a larger pain reduction. Two of the trials had three arms and share one control group between two comparisons; the trial column marks them and respondeR splits each shared control arm so it is not counted twice. Using a 1.5 cm reduction as the MID:

vas <- responder_analysis(vas_pain, mid = -1.5, direction = "lower",
                          pooling = "random", ci_method = "hksj_adhoc")
#> Split the shared control arm of multi-arm trial(s) Farzaneh 2022, Peng Qiu 2014 across their comparisons (control n divided equally) to avoid double counting. This message is shown once per session.
vas
#> Responder analysis (respondeR)
#>   MID = -1.5 (a lower change is a response); k = 20 studies; random
#>   effects; delta per-study variance
#>   Percentages; 95% confidence intervals.
#> 
#>           Method   PE   PC                  RD                  RR
#>       Individual    -    - 17.5 (10.8 to 24.3) 1.20 (1.09 to 1.33)
#>    Weighted mean 79.2 52.3 26.9 (18.9 to 34.8) 1.51 (1.25 to 1.83)
#>  Unweighted mean 78.7 47.1                31.6                1.67
#>           Median 83.9 37.3                46.7                2.25
#>                   OR                   NNT
#>  3.90 (2.54 to 5.99) NNTB 5.7 (4.1 to 9.3)
#>  3.47 (2.44 to 4.92) NNTB 3.7 (2.9 to 5.3)
#>                 4.14              NNTB 3.2
#>                 8.81              NNTB 2.1
#> 
#>   individual: I-squared 62%, tau-squared 0.00712, Q 49.67 (p 0.000); prediction interval -1.5 to 36.5
#> 
#> Use as.data.frame() for every column, summary() for details, plot() for a forest plot.

Pooling the per-study estimates (the individual method, which keeps every comparison within its randomized trial), about 18 more exercise patients per 100 reach a 1.5 cm pain reduction than controls. The descriptive unweighted and median summaries are larger and more dispersed; that spread reflects the heterogeneity across trials (I-squared above 50 percent), and they carry no interval. In summary-of-findings form:

responder_sof(vas[vas$method == "individual", ])
#> Summary of findings (responders)
#> 
#>      Method Control risk         Intervention risk
#>  individual 418 per 1000 737 per 1000 (646 to 812)
#>                                Difference        Relative effect
#>  319 more per 1000 (228 more to 393 more) OR 3.90 (2.54 to 5.99)

and without any threshold:

cles <- responder_cles(vas_pain, direction = "lower", pooling = "random")
sprintf("A treated patient has less pain than a control %.0f%% of the time (%.0f%% to %.0f%%)",
        100 * cles$cles, 100 * cles$cles_lb, 100 * cles$cles_ub)
#> [1] "A treated patient has less pain than a control 73% of the time (67% to 78%)"

(Data from Li et al. (2025), Frontiers in Sports and Active Living, doi:10.3389/fspor.2025.1614906, Figure 3, reproduced under CC BY 4.0.)

Fitted meta-analyses

If you already have a meta::metacont() or metafor::rma() fit, pass it to responder_analysis() directly; see vignette("meta-analysis-objects").

The Shiny application

Everything above is available in a point-and-click app:

launch_responder_analysis()

The same tool runs in the browser, with no installation, at https://choxos.github.io/respondeR/app/.

Where next

See vignette("methodology") for the full statistical detail: each method’s estimator and variance, the relative measures, the SMD bridge, the interval options, assumptions and a method-choice guide.