Skip to content

Commit 6a164ff

Browse files
committed
Add negative control analysis functions and documentation
- Implemented `nc_correct()` for bias correction using negative controls. - Created `nc_detect()` for detecting bias with negative controls. - Developed `nc_data()` to prepare datasets for negative control analysis. - Introduced `nc_model()` for specifying models in negative control analysis. - Added `nc_spec()` to declare variable roles in negative control analysis. - Created result classes `nc_correct_result` and `nc_detect_result` for structured output. - Added comprehensive tests for all new functions and error handling. - Updated package documentation for all new functions and classes.
1 parent d154a8b commit 6a164ff

37 files changed

Lines changed: 2022 additions & 28 deletions

.claude/settings.local.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
{
22
"permissions": {
33
"allow": [
4-
"Bash(grep -E '\\\\.\\(Rmd|md|LICENSE|NAMESPACE\\)$')"
4+
"Bash(grep -E '\\\\.\\(Rmd|md|LICENSE|NAMESPACE\\)$')",
5+
"Bash(Rscript -e \"devtools::document\\(\\)\")",
6+
"Bash(Rscript -e \"devtools::test\\(\\)\")"
57
]
68
}
79
}

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# negatr
22

3-
R package for...
3+
- This is an R package (suitable for CRAN) to detect and correct for biases--especially residual confounding, selection bias, and measurement error--with negative control exposures and outcomes, primarily for epidemiological studies.
4+
- The aim is to create a one-stop resource for all methods related to negative controls.
5+
- The package should follow best coding practices. For instance: reusing code, proper documentation, proper unit testing, continuous integration.
46

57
## Project structure
68

DESCRIPTION

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
11
Package: negatr
2-
Title: What the Package Does (One Line, Title Case)
2+
Title: Negative Control Methods for Bias Detection and Correction
33
Version: 0.0.0.9000
4-
Authors@R:
4+
Authors@R:
55
person("Lorenzo", "Fabbri", , "lorenzo.fabbri92sm@gmail.com", role = c("aut", "cre"),
66
comment = c(ORCID = "0000-0003-3031-322X"))
7-
Description: What the package does (one paragraph).
7+
Description: A comprehensive framework for detecting and correcting biases in
8+
epidemiological studies using negative control exposures and outcomes.
9+
Supports methods for unmeasured confounding, selection bias, and
10+
measurement error, ranging from simple null hypothesis tests to
11+
proximal causal inference and empirical calibration.
812
License: MIT + file LICENSE
9-
Suggests:
13+
Imports:
14+
rlang (>= 1.0.0),
15+
stats
16+
Suggests:
17+
dagitty,
18+
mgcv,
19+
survival,
1020
testthat (>= 3.0.0)
1121
Config/testthat/edition: 3
1222
Encoding: UTF-8
1323
Roxygen: list(markdown = TRUE)
1424
RoxygenNote: 7.3.3
15-
URL: https://github.com/lorenzoFabbri/negatr
16-
BugReports: https://github.com/lorenzoFabbri/negatr/issues
25+
URL: https://github.com/etverse/negatr
26+
BugReports: https://github.com/etverse/negatr/issues

DOUBTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
- should it support multiply imputed datasets from `mice` (https://github.com/amices/mice)?
2+
- should we separate methods based on the type of bias (unmeasured confounding, selection bias, or measurement bias)? They might require different strategies (like in https://doi.org/10.1097%2FeDe.0000000000000504).

METHODS.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Summary of Methods
2+
3+
Notation:
4+
5+
- _A_ is the primary exposure.
6+
- _X_ is a vector of measured confounders.
7+
- _U_ is a vector of unmeasured confounders.
8+
- _Y_ is the primary outcome.
9+
- _Z_ is the negative control exposure.
10+
- _W_ is the negative control outcome.
11+
12+
## Simple detection methods
13+
14+
### Null-hypothesis testing
15+
16+
> Formal assessment of whether the negative control association is statistically significant.
17+
18+
- Key assumption: _U_-comparability. The unmeasured confounders _U_ of _A_-_Y_ are the same to those of _A_-_W_ and _Z_-_Y_.
19+
- When evaluating _Z_-_Y_, we need to adjust for _A_ (possibility of _Z_-_A_ $\rightarrow$ _Y_).
20+
- Example: Wald test of the coefficient of the negative control exposure in a regression model.
21+
22+
References:
23+
24+
- [x] Shi, X., Miao, W. & Tchetgen, E.T. A Selective Review of Negative Control Methods in Epidemiology. Curr Epidemiol Rep 7, 190–202 (2020). https://doi.org/10.1007/s40471-020-00243-4
25+
- [x] Lipsitch, Marc, Tchetgen Tchetgen, Eric, Cohen, Ted. Negative Controls: A Tool for Detecting Confounding and Bias in Observational Studies. Epidemiology 21(3):p 383-388, May 2010. https://doi.org/10.1097/EDE.0b013e3181d61eeb
26+
- [ ] Levintow SN, Nielson CM, Hernandez RK, et al. Pragmatic considerations for negative control outcome studies to guide non-randomized comparative analyses: A narrative review. Pharmacoepidemiol Drug Saf. 2023; 32(6): 599-606. https://doi.org/10.1002/pds.5623
27+
- [x] Arnold, Benjamin F., Ercumen, Ayse, Benjamin-Chung, Jade, Colford, John M. Jr. Brief Report: Negative Controls to Detect Selection Bias and Measurement Bias in Epidemiologic Studies. Epidemiology 27(5):p 637-641, September 2016. https://doi.org/10.1097/EDE.0000000000000504
28+
29+
## Restrictive analytical methods
30+
31+
### Restriction or stratification
32+
33+
> Restriction of the analytical method to subgroup of the population where the negative control association is null.
34+
35+
References:
36+
37+
- [ ] Chase D Latour, Megan Delgado, I-Hsuan Su, Catherine Wiener, Clement O Acheampong, Charles Poole, Jessie K Edwards, Kenneth Quinto, Til Stürmer, Jennifer L Lund, Jie Li, Nahleen Lopez, John Concato, Michele Jonsson Funk. Use of sensitivity analyses to assess uncontrolled confounding from unmeasured variables in observational, active comparator pharmacoepidemiologic studies: a systematic review. American Journal of Epidemiology, Volume 194, Issue 2, February 2025, Pages 524–535. https://doi.org/10.1093/aje/kwae234
38+
- [ ] Gutierrez, S., Glymour, M.M. & Smith, G.D. Evidence triangulation in health research. Eur J Epidemiol 40, 743–757 (2025). https://doi.org/10.1007/s10654-024-01194-6
39+
40+
## Simple bias adjustment methods
41+
42+
### Difference-in-differences
43+
44+
References:
45+
46+
- [ ]

NAMESPACE

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,16 @@
11
# Generated by roxygen2: do not edit by hand
22

3+
S3method(print,nc_assume_result)
4+
S3method(print,nc_correct_result)
5+
S3method(print,nc_data)
6+
S3method(print,nc_detect_result)
7+
S3method(print,nc_model)
8+
S3method(print,nc_spec)
9+
export(nc_assume)
10+
export(nc_correct)
11+
export(nc_correct_result)
12+
export(nc_data)
13+
export(nc_detect)
14+
export(nc_detect_result)
15+
export(nc_model)
16+
export(nc_spec)

R/nc-assume.R

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#' Check assumptions for a negative control analysis
2+
#'
3+
#' `nc_assume()` evaluates whether the dataset and specification are consistent
4+
#' with core assumptions required by negative control methods. Checks are
5+
#' performed heuristically (e.g. via regression tests) and should be treated
6+
#' as diagnostic aids, not proofs.
7+
#'
8+
#' Checks performed:
9+
#'
10+
#' - **Exclusion restriction** (`"exclusion"`): Tests whether the negative
11+
#' control exposure (NCE) is associated with the primary outcome after
12+
#' adjusting for the primary exposure and covariates. A significant
13+
#' association is evidence against the exclusion restriction.
14+
#' - **U-comparability** (`"u_comparability"`): Tests whether the NCE is
15+
#' associated with the primary exposure. A significant association is
16+
#' consistent with U-comparability: the NCE and the primary exposure share
17+
#' the same unmeasured confounders.
18+
#'
19+
#' @param ncdata `[nc_data]`\cr
20+
#' Dataset prepared with [nc_data()].
21+
#' @param checks `[character()]`\cr
22+
#' Which assumptions to check. Defaults to all available checks.
23+
#' @param model `[nc_model]`\cr
24+
#' Model specification for regression-based checks. Defaults to OLS.
25+
#' @param ... `[any]`\cr
26+
#' Reserved for future use.
27+
#'
28+
#' @return An object of class `nc_assume_result`: a named list where each
29+
#' element corresponds to one check and contains `$passed` (`TRUE`/`FALSE`/
30+
#' `NA`), `$message`, and optionally `$model_fit`.
31+
#'
32+
#' @export
33+
#'
34+
#' @examples
35+
#' df <- data.frame(
36+
#' A = rbinom(200, 1, 0.5),
37+
#' Y = rnorm(200),
38+
#' Z = rbinom(200, 1, 0.5),
39+
#' W = rnorm(200),
40+
#' age = rnorm(200)
41+
#' )
42+
#' spec <- nc_spec(
43+
#' exposure = "A", outcome = "Y",
44+
#' nce = "Z", nco = "W",
45+
#' covariates = "age"
46+
#' )
47+
#' nd <- nc_data(df, spec)
48+
#' nc_assume(nd)
49+
nc_assume <- function(
50+
ncdata,
51+
checks = c("exclusion", "u_comparability"),
52+
model = nc_model(stats::lm),
53+
...) {
54+
if (!inherits(ncdata, "nc_data")) {
55+
rlang::abort("`ncdata` must be an `nc_data` object created by `nc_data()`.")
56+
}
57+
if (!inherits(model, "nc_model")) {
58+
rlang::abort("`model` must be an `nc_model` object created by `nc_model()`.")
59+
}
60+
61+
supported <- c("exclusion", "u_comparability")
62+
for (ch in checks) check_choice(ch, supported)
63+
64+
spec <- attr(ncdata, "spec")
65+
results <- list()
66+
67+
if ("exclusion" %in% checks) {
68+
results[["exclusion"]] <- check_exclusion(ncdata, spec, model)
69+
}
70+
if ("u_comparability" %in% checks) {
71+
results[["u_comparability"]] <- check_u_comparability(ncdata, spec, model)
72+
}
73+
74+
structure(results, class = "nc_assume_result")
75+
}
76+
77+
#' @export
78+
print.nc_assume_result <- function(x, ...) {
79+
cat("Negative control assumption checks\n")
80+
for (nm in names(x)) {
81+
item <- x[[nm]]
82+
status <- if (isTRUE(item$passed)) "[PASS]" else if (isFALSE(item$passed)) "[FAIL]" else "[WARN]"
83+
cat(" ", status, nm, ":", item$message, "\n")
84+
}
85+
invisible(x)
86+
}
87+
88+
check_exclusion <- function(ncdata, spec, model) {
89+
if (is.null(spec$nce)) {
90+
return(list(
91+
passed = NA,
92+
message = "No NCE specified; exclusion restriction cannot be checked."
93+
))
94+
}
95+
96+
predictors <- c(
97+
spec$nce,
98+
build_predictors(spec, include = c("exposure", "covariates"))
99+
)
100+
fit <- fit_model(spec, ncdata, spec$outcome, predictors, model)
101+
cs <- summary(fit)$coefficients
102+
103+
if (!spec$nce %in% rownames(cs)) {
104+
return(list(
105+
passed = NA,
106+
message = "NCE coefficient not found in model; check model specification.",
107+
model_fit = fit
108+
))
109+
}
110+
111+
p_val <- cs[spec$nce, 4L]
112+
passed <- p_val >= 0.05
113+
msg <- if (passed) {
114+
sprintf(
115+
"NCE not significantly associated with outcome (p = %.3f).",
116+
p_val
117+
)
118+
} else {
119+
sprintf(
120+
"NCE is significantly associated with outcome (p = %.3f); exclusion restriction may be violated.",
121+
p_val
122+
)
123+
}
124+
125+
list(passed = passed, message = msg, model_fit = fit)
126+
}
127+
128+
check_u_comparability <- function(ncdata, spec, model) {
129+
if (is.null(spec$nce)) {
130+
return(list(
131+
passed = NA,
132+
message = "No NCE specified; U-comparability cannot be checked."
133+
))
134+
}
135+
136+
predictors <- c(
137+
spec$nce,
138+
build_predictors(spec, include = c("covariates"))
139+
)
140+
fit <- fit_model(spec, ncdata, spec$exposure, predictors, model)
141+
cs <- summary(fit)$coefficients
142+
143+
if (!spec$nce %in% rownames(cs)) {
144+
return(list(
145+
passed = NA,
146+
message = "NCE coefficient not found in model; check model specification.",
147+
model_fit = fit
148+
))
149+
}
150+
151+
p_val <- cs[spec$nce, 4L]
152+
passed <- p_val < 0.05
153+
msg <- if (passed) {
154+
sprintf(
155+
"NCE is associated with primary exposure (p = %.3f), consistent with U-comparability.",
156+
p_val
157+
)
158+
} else {
159+
sprintf(
160+
"NCE is not associated with primary exposure (p = %.3f); U-comparability may not hold.",
161+
p_val
162+
)
163+
}
164+
165+
list(passed = passed, message = msg, model_fit = fit)
166+
}

R/nc-correct.R

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#' Correct for bias using negative controls
2+
#'
3+
#' `nc_correct()` applies a bias correction method that uses negative control
4+
#' variables to produce a bias-adjusted estimate of the primary
5+
#' exposure-outcome association.
6+
#'
7+
#' Currently implemented methods:
8+
#'
9+
#' - `"diff_in_diff"`: Difference-in-differences (simple subtraction). Corrects
10+
#' the primary estimate by subtracting the negative control association,
11+
#' under the equi-confounding assumption. Requires a negative control outcome
12+
#' (`nco`).
13+
#'
14+
#' @param ncdata `[nc_data]`\cr
15+
#' Dataset prepared with [nc_data()].
16+
#' @param method `[character(1)]`\cr
17+
#' Correction method to use. See Details for available methods.
18+
#' @param model `[nc_model]`\cr
19+
#' Model specification created with [nc_model()]. Defaults to OLS via
20+
#' [stats::lm()].
21+
#' @param ... `[any]`\cr
22+
#' Additional arguments passed to the specific correction method.
23+
#'
24+
#' @return An [nc_correct_result()] object.
25+
#'
26+
#' @export
27+
#'
28+
#' @examples
29+
#' df <- data.frame(
30+
#' A = rbinom(200, 1, 0.5),
31+
#' Y = rnorm(200),
32+
#' W = rnorm(200),
33+
#' age = rnorm(200)
34+
#' )
35+
#' spec <- nc_spec(exposure = "A", outcome = "Y", nco = "W", covariates = "age")
36+
#' nd <- nc_data(df, spec)
37+
#' nc_correct(nd, method = "diff_in_diff")
38+
nc_correct <- function(ncdata, method = "diff_in_diff", model = nc_model(stats::lm), ...) {
39+
if (!inherits(ncdata, "nc_data")) {
40+
rlang::abort("`ncdata` must be an `nc_data` object created by `nc_data()`.")
41+
}
42+
if (!inherits(model, "nc_model")) {
43+
rlang::abort("`model` must be an `nc_model` object created by `nc_model()`.")
44+
}
45+
46+
supported <- c("diff_in_diff")
47+
check_choice(method, supported)
48+
49+
spec <- attr(ncdata, "spec")
50+
51+
switch(method,
52+
diff_in_diff = nc_correct_diff_in_diff(ncdata, spec, model, rlang::current_call(), ...)
53+
)
54+
}
55+
56+
nc_correct_diff_in_diff <- function(ncdata, spec, model, call, ...) {
57+
if (is.null(spec$nco)) {
58+
rlang::abort(
59+
"The `diff_in_diff` method requires a negative control outcome (`nco`).",
60+
call = call
61+
)
62+
}
63+
64+
primary_predictors <- build_predictors(spec, include = c("exposure", "covariates"))
65+
fit_primary <- fit_model(spec, ncdata, spec$outcome, primary_predictors, model)
66+
67+
nco_predictors <- build_predictors(spec, include = c("exposure", "covariates"))
68+
fit_nco <- fit_model(spec, ncdata, spec$nco, nco_predictors, model)
69+
70+
primary_coefs <- stats::coef(fit_primary)
71+
nco_coefs <- stats::coef(fit_nco)
72+
73+
if (!spec$exposure %in% names(primary_coefs)) {
74+
rlang::abort(
75+
paste0("Coefficient for `", spec$exposure, "` not found in primary model."),
76+
call = call
77+
)
78+
}
79+
if (!spec$exposure %in% names(nco_coefs)) {
80+
rlang::abort(
81+
paste0("Coefficient for `", spec$exposure, "` not found in NCO model."),
82+
call = call
83+
)
84+
}
85+
86+
beta_primary <- primary_coefs[[spec$exposure]]
87+
beta_nco <- nco_coefs[[spec$exposure]]
88+
corrected <- beta_primary - beta_nco
89+
90+
nc_correct_result(
91+
estimate = corrected,
92+
se = NULL,
93+
ci = NULL,
94+
method = "diff_in_diff",
95+
nc_type = "nco",
96+
model_fits = list(primary = fit_primary, nco = fit_nco),
97+
call = call
98+
)
99+
}

0 commit comments

Comments
 (0)