Skip to content

Commit a59eb48

Browse files
authored
Merge pull request #4 from jeliason/feature/spatial-covariates
Feature/spatial covariates
2 parents afcba69 + f3c4fcf commit a59eb48

35 files changed

Lines changed: 2527 additions & 4107 deletions

‎NAMESPACE‎

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

33
export("%>%")
4+
export(add_pointwise_bands)
5+
export(add_simultaneous_bands)
6+
export(compute_sic_posterior)
7+
export(extract_group_sics)
8+
export(extract_image_sics)
9+
export(extract_patient_sics)
410
export(make_data)
511
export(make_dispersions)
612
export(make_pat)

‎R/credible_bands.R‎

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
#' Add Simultaneous Credible Bands to SIC Posterior Data
2+
#'
3+
#' Computes simultaneous credible bands that control the family-wise error rate
4+
#' across all distances. This ensures the entire curve falls within the band with
5+
#' (1-alpha)% probability, rather than just pointwise coverage.
6+
#'
7+
#' @param sic_data Data frame containing rvar columns (from posterior package)
8+
#' representing SIC posteriors. Must have a column identifying distance coordinates.
9+
#' @param distance_col Name of the column containing distance values (default: "distance").
10+
#' @param sic_cols Character vector of rvar column names to compute bands for.
11+
#' If NULL (default), processes all rvar columns except the distance column.
12+
#' @param alpha Significance level (default 0.05 for 95% simultaneous bands).
13+
#' @param keep_rvar Logical; if TRUE, keeps the original rvar column. Default FALSE.
14+
#'
15+
#' @return A data frame with the original data plus columns for each SIC:
16+
#' \item{<sic>_mean}{Posterior mean}
17+
#' \item{<sic>_lower}{Simultaneous lower bound}
18+
#' \item{<sic>_upper}{Simultaneous upper bound}
19+
#'
20+
#' @details
21+
#' The algorithm:
22+
#' 1. Extracts mean and SD at each distance for each rvar column
23+
#' 2. Standardizes posterior deviations: (draw - mean) / SD
24+
#' 3. Finds maximum absolute deviation across all distances for each draw
25+
#' 4. Uses the (1-alpha) quantile of these maxima as the critical value
26+
#' 5. Constructs bands: mean ± critical_value × SD
27+
#'
28+
#' Simultaneous bands are wider than pointwise bands but provide stronger guarantees:
29+
#' the entire curve is contained with (1-alpha)% probability.
30+
#'
31+
#' @examples
32+
#' \dontrun{
33+
#' library(dplyr)
34+
#' library(posterior)
35+
#'
36+
#' # Compute SIC posteriors
37+
#' sics <- compute_sic_posterior(fit, prep, level = "group")
38+
#'
39+
#' # Add simultaneous bands
40+
#' sics_with_bands <- sics %>%
41+
#' add_simultaneous_bands(alpha = 0.1)
42+
#'
43+
#' # Plot
44+
#' ggplot(sics_with_bands, aes(x = distance, y = sic_mean)) +
45+
#' geom_ribbon(aes(ymin = sic_lower, ymax = sic_upper), alpha = 0.2) +
46+
#' geom_line() +
47+
#' facet_wrap(~source)
48+
#' }
49+
#'
50+
#' @export
51+
add_simultaneous_bands <- function(sic_data,
52+
distance_col = "distance",
53+
sic_cols = NULL,
54+
alpha = 0.05,
55+
keep_rvar = FALSE) {
56+
57+
# Validate inputs
58+
if (!distance_col %in% names(sic_data)) {
59+
stop(sprintf("distance_col '%s' not found in sic_data", distance_col))
60+
}
61+
62+
# Identify rvar columns to process
63+
if (is.null(sic_cols)) {
64+
sic_cols <- names(sic_data)[sapply(sic_data, posterior::is_rvar)]
65+
sic_cols <- setdiff(sic_cols, distance_col)
66+
}
67+
68+
if (length(sic_cols) == 0) {
69+
stop("No rvar columns found in sic_data")
70+
}
71+
72+
# Validate that specified columns exist and are rvars
73+
missing_cols <- setdiff(sic_cols, names(sic_data))
74+
if (length(missing_cols) > 0) {
75+
stop(sprintf("Columns not found: %s", paste(missing_cols, collapse = ", ")))
76+
}
77+
78+
non_rvar_cols <- sic_cols[!sapply(sic_data[sic_cols], posterior::is_rvar)]
79+
if (length(non_rvar_cols) > 0) {
80+
stop(sprintf("Columns are not rvar type: %s", paste(non_rvar_cols, collapse = ", ")))
81+
}
82+
83+
# Convert to data frame for processing
84+
result <- as.data.frame(sic_data)
85+
86+
# Step 1: Extract mean and SD at each distance
87+
for (col in sic_cols) {
88+
result[[paste0(col, "_mean")]] <- as.vector(posterior::E(result[[col]]))
89+
result[[paste0(col, "_sd")]] <- as.vector(posterior::sd(result[[col]]))
90+
}
91+
92+
# Step 2: Standardize residuals
93+
# Extract just the rvar columns into a separate data frame
94+
lp_data <- result[sic_cols]
95+
96+
# Standardize each column
97+
lp_std <- lapply(sic_cols, function(col) {
98+
rvar_col <- lp_data[[col]]
99+
# Compute mean and sd for this column using posterior package functions
100+
col_mean <- mean(rvar_col)
101+
col_sd <- posterior::sd(rvar_col) # Use posterior::sd for rvar
102+
# Standardize
103+
posterior::as_rvar((rvar_col - col_mean) / col_sd)
104+
})
105+
names(lp_std) <- sic_cols
106+
107+
# Step 3: Find maximum deviation across distances for each posterior draw
108+
max_dev <- sapply(lp_std, function(col) posterior::rvar_max(abs(col)))
109+
110+
# Step 4: Find critical value for simultaneous coverage
111+
z_score_band <- sapply(max_dev, quantile, probs = 1 - alpha, na.rm = TRUE)
112+
113+
# Step 5: Construct simultaneous lower and upper bands
114+
for (col in sic_cols) {
115+
result[[paste0(col, "_lower")]] <- result[[paste0(col, "_mean")]] -
116+
z_score_band[[col]] * result[[paste0(col, "_sd")]]
117+
result[[paste0(col, "_upper")]] <- result[[paste0(col, "_mean")]] +
118+
z_score_band[[col]] * result[[paste0(col, "_sd")]]
119+
120+
# Remove rvar column if requested
121+
if (!keep_rvar) {
122+
result[[col]] <- NULL
123+
}
124+
125+
# Remove SD column (internal use only)
126+
result[[paste0(col, "_sd")]] <- NULL
127+
}
128+
129+
return(result)
130+
}
131+
132+
133+
#' Add Pointwise Credible Bands to SIC Posterior Data
134+
#'
135+
#' Computes pointwise credible bands using simple quantiles at each distance.
136+
#' Unlike simultaneous bands, these only guarantee pointwise coverage - each
137+
#' individual point has (1-alpha) probability of being in the band, but the
138+
#' entire curve may not be contained.
139+
#'
140+
#' @param sic_data Data frame containing rvar columns (from posterior package)
141+
#' representing SIC posteriors. Must have a column identifying distance coordinates.
142+
#' @param distance_col Name of the column containing distance values (default: "distance").
143+
#' @param sic_cols Character vector of rvar column names to compute bands for.
144+
#' If NULL (default), processes all rvar columns except the distance column.
145+
#' @param alpha Significance level (default 0.05 for 95% pointwise bands).
146+
#' @param keep_rvar Logical; if TRUE, keeps the original rvar column. Default FALSE.
147+
#'
148+
#' @return A data frame with the original data plus columns for each SIC:
149+
#' \item{<sic>_mean}{Posterior mean}
150+
#' \item{<sic>_lower}{Pointwise lower bound}
151+
#' \item{<sic>_upper}{Pointwise upper bound}
152+
#'
153+
#' @details
154+
#' The algorithm:
155+
#' 1. Extracts mean at each distance for each rvar column
156+
#' 2. Computes (alpha/2) and (1-alpha/2) quantiles at each distance independently
157+
#' 3. Returns mean with quantile-based bounds
158+
#'
159+
#' Pointwise bands are narrower than simultaneous bands but provide weaker guarantees:
160+
#' each individual distance has (1-alpha)% coverage, but multiple testing issues arise.
161+
#'
162+
#' @examples
163+
#' \dontrun{
164+
#' library(dplyr)
165+
#' library(posterior)
166+
#'
167+
#' # Compute SIC posteriors
168+
#' sics <- compute_sic_posterior(fit, prep, level = "group")
169+
#'
170+
#' # Add pointwise bands
171+
#' sics_with_bands <- sics %>%
172+
#' add_pointwise_bands(alpha = 0.05)
173+
#'
174+
#' # Compare with simultaneous bands
175+
#' sics_simul <- add_simultaneous_bands(sics, alpha = 0.05)
176+
#' }
177+
#'
178+
#' @export
179+
add_pointwise_bands <- function(sic_data,
180+
distance_col = "distance",
181+
sic_cols = NULL,
182+
alpha = 0.05,
183+
keep_rvar = FALSE) {
184+
185+
# Validate inputs
186+
if (!distance_col %in% names(sic_data)) {
187+
stop(sprintf("distance_col '%s' not found in sic_data", distance_col))
188+
}
189+
190+
# Identify rvar columns to process
191+
if (is.null(sic_cols)) {
192+
sic_cols <- names(sic_data)[sapply(sic_data, posterior::is_rvar)]
193+
sic_cols <- setdiff(sic_cols, distance_col)
194+
}
195+
196+
if (length(sic_cols) == 0) {
197+
stop("No rvar columns found in sic_data")
198+
}
199+
200+
# Validate that specified columns exist and are rvars
201+
missing_cols <- setdiff(sic_cols, names(sic_data))
202+
if (length(missing_cols) > 0) {
203+
stop(sprintf("Columns not found: %s", paste(missing_cols, collapse = ", ")))
204+
}
205+
206+
non_rvar_cols <- sic_cols[!sapply(sic_data[sic_cols], posterior::is_rvar)]
207+
if (length(non_rvar_cols) > 0) {
208+
stop(sprintf("Columns are not rvar type: %s", paste(non_rvar_cols, collapse = ", ")))
209+
}
210+
211+
# Process each SIC column
212+
result <- sic_data
213+
214+
for (col in sic_cols) {
215+
rvar_col <- result[[col]]
216+
217+
# Compute mean and quantiles at each distance
218+
result <- result %>%
219+
dplyr::mutate(
220+
!!paste0(col, "_mean") := as.vector(posterior::E(rvar_col)),
221+
!!paste0(col, "_lower") := sapply(rvar_col, quantile, probs = alpha / 2),
222+
!!paste0(col, "_upper") := sapply(rvar_col, quantile, probs = 1 - alpha / 2)
223+
)
224+
225+
# Remove rvar column if requested
226+
if (!keep_rvar) {
227+
result[[col]] <- NULL
228+
}
229+
}
230+
231+
return(result)
232+
}

‎R/data_matrix.R‎

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,40 +152,80 @@ make_rbfs <- function(max_dist,
152152
#' @param Q Logistic regression quadrature scheme, as generated by make_quadrature
153153
#' @param potentials Named list of potential functions, each taking a distance as input
154154
#' @param focal_cell Focal cell type (character) for which the regression is computed
155+
#' @param covariates Optional matrix of spatial covariates with one row per quadrature point
156+
#' (both data and dummy points for the focal cell). Columns represent different covariates.
157+
#' If provided, these will be appended to the design matrix after dispersion features.
155158
#' @param verbose Logical flag for verbosity (default: FALSE)
156159
#'
157-
#' @return A list containing the design matrix (`data`) with an intercept and dispersion features,
158-
#' and the response vector (`response`) indicating data (1) vs. dummy (0) points.
160+
#' @return A list containing the design matrix (`data`) with an intercept, dispersion features,
161+
#' and optional covariates; and the response vector (`response`) indicating data (1) vs. dummy (0) points.
159162
#' @export
160163
make_data <- function(Q,
161164
potentials,
162165
focal_cell = NULL,
166+
covariates = NULL,
163167
verbose = FALSE) {
164-
168+
165169
# Ensure a focal cell is specified
166170
if (is.null(focal_cell)) {
167171
stop("focal_cell must be specified")
168172
}
169-
173+
170174
# Compute dispersion features using the provided potential functions
171175
dispersions <- make_dispersions(Q, focal_cell, potentials, verbose)
172-
176+
173177
# Subset quadrature scheme to the focal cell points using spatstat.core's quadscheme.logi
174178
Q_focal <- spatstat.geom::quadscheme.logi(
175179
Q$data[spatstat.geom::marks(Q$data) == focal_cell],
176180
Q$dummy[spatstat.geom::marks(Q$dummy) == focal_cell]
177181
)
178-
182+
179183
# Drop unused factor levels in marks for both data and dummy points
180184
spatstat.geom::marks(Q_focal$data) <- droplevels(spatstat.geom::marks(Q_focal$data))
181185
spatstat.geom::marks(Q_focal$dummy) <- droplevels(spatstat.geom::marks(Q_focal$dummy))
182-
186+
183187
# Construct the response vector: 1 for data points, 0 for dummy points
184188
response <- as.numeric(spatstat.geom::is.data(Q_focal))
185-
186-
# Build the design matrix by combining an intercept with the dispersion features
187-
design_matrix <- cbind(beta0 = 1, dispersions)
188-
189+
190+
# Validate covariates if provided
191+
if (!is.null(covariates)) {
192+
# Convert to matrix if needed
193+
if (!is.matrix(covariates) && !inherits(covariates, "Matrix")) {
194+
covariates <- as.matrix(covariates)
195+
}
196+
197+
# Check dimensions
198+
expected_rows <- length(response)
199+
if (nrow(covariates) != expected_rows) {
200+
stop(sprintf("covariates must have %d rows (one per quadrature point for focal cell), but has %d rows",
201+
expected_rows, nrow(covariates)))
202+
}
203+
204+
# Check for missing values
205+
if (any(is.na(covariates))) {
206+
stop("covariates cannot contain missing values (NA)")
207+
}
208+
209+
# Check for constant covariates (will cause numerical issues)
210+
constant_cols <- apply(covariates, 2, function(x) length(unique(x)) == 1)
211+
if (any(constant_cols)) {
212+
warning(sprintf("Covariate(s) %s are constant and may cause numerical issues",
213+
paste(which(constant_cols), collapse = ", ")))
214+
}
215+
216+
# Ensure column names exist
217+
if (is.null(colnames(covariates))) {
218+
colnames(covariates) <- paste0("cov", seq_len(ncol(covariates)))
219+
}
220+
}
221+
222+
# Build the design matrix by combining intercept, dispersion features, and optional covariates
223+
if (is.null(covariates)) {
224+
design_matrix <- cbind(beta0 = 1, dispersions)
225+
} else {
226+
design_matrix <- cbind(beta0 = 1, dispersions, covariates)
227+
}
228+
189229
return(list(data = design_matrix, response = response))
190230
}
191231

0 commit comments

Comments
 (0)