Skip to content

Commit b2f66de

Browse files
committed
Generally, ergm constraint specifications can now be term_lists and potentially heterogeneous lists of term_lists, formulas (one- or two-sided), and character strings. A new helper function, ergm_flatten_conterm_list() can be used to take any of these and conver them to a flat term_list of unique constraints. Also, control.ergm(obs.MCMC.prop=) argument's default is now NULL, since MCMC.prop's hints will carry over anyway.
1 parent f384bca commit b2f66de

14 files changed

Lines changed: 205 additions & 87 deletions

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
Package: ergm
2-
Version: 4.13.0-8019
2+
Version: 4.13.0-8020
33
Date: 2026-03-20
44
Title: Fit, Simulate and Diagnose Exponential-Family Models for Networks
55
Authors@R: c(

NAMESPACE

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,10 @@ S3method(ergm_attr_levels,logical)
5959
S3method(ergm_attr_levels,matrix)
6060
S3method(ergm_attr_levels,numeric)
6161
S3method(ergm_conlist,"NULL")
62+
S3method(ergm_conlist,character)
6263
S3method(ergm_conlist,ergm_conlist)
6364
S3method(ergm_conlist,formula)
65+
S3method(ergm_conlist,list)
6466
S3method(ergm_conlist,term_list)
6567
S3method(ergm_get_vattr,"function")
6668
S3method(ergm_get_vattr,AsIs)
@@ -246,6 +248,7 @@ export(ergm_conlist)
246248
export(ergm_cutoff_message)
247249
export(ergm_dyadgen_select)
248250
export(ergm_edgecov_args)
251+
export(ergm_flatten_conterm_list)
249252
export(ergm_get_vattr)
250253
export(ergm_keyword)
251254
export(ergm_mk_std_op_namewrap)

R/control.ergm.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,7 @@ control.ergm<-function(drop=TRUE,
588588
obs.MCMC.interval=EVL(round(MCMC.interval*obs.MCMC.interval.mul)),
589589
obs.MCMC.burnin.mul=sqrt(obs.MCMC.mul),
590590
obs.MCMC.burnin=EVL(round(MCMC.burnin*obs.MCMC.burnin.mul)),
591-
obs.MCMC.prop=MCMC.prop, obs.MCMC.prop.weights=MCMC.prop.weights, obs.MCMC.prop.args=MCMC.prop.args,
591+
obs.MCMC.prop=NULL, obs.MCMC.prop.weights=MCMC.prop.weights, obs.MCMC.prop.args=MCMC.prop.args,
592592
obs.MCMC.impute.min_informative = function(nw) network.size(nw)/4,
593593
obs.MCMC.impute.default_density = function(nw) 2/network.size(nw),
594594

R/ergm.R

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -368,13 +368,13 @@ ergm <- function(formula, response=NULL,
368368

369369
if(!is(constraints, "ergm_proposal")){
370370
# Handle the observation process and other "automatic" constraints.
371-
tmp <- .handle.auto.constraints(nw, constraints, obs.constraints, target.stats)
371+
tmp <- .handle.auto.constraints(nw, constraints, obs.constraints, target.stats, control)
372372
nw <- tmp$nw
373373
conterms.obs <- tmp$conterms.obs
374374
conterms <- tmp$conterms
375375
}else if(!is(obs.constraints, "ergm_proposal")){
376376
# Handle the observation process and other "automatic" constraints.
377-
tmp <- .handle.auto.constraints(nw, trim_env(~.), obs.constraints, target.stats)
377+
tmp <- .handle.auto.constraints(nw, trim_env(~.), obs.constraints, target.stats, control)
378378
nw <- tmp$nw
379379
conterms.obs <- tmp$conterms.obs
380380
conterms <- tmp$conterms
@@ -388,15 +388,19 @@ ergm <- function(formula, response=NULL,
388388
warn(paste0("The default Bernoulli reference distribution operates in the binary (",sQuote("response=NULL"),") mode only. Did you specify the ",sQuote("reference")," argument?"))
389389
}
390390

391-
proposal <- ergm_proposal(conterms, hints=control$MCMC.prop, weights=control$MCMC.prop.weights, control$MCMC.prop.args, nw, class=proposalclass,reference=reference, term.options=control$term.options)
391+
proposal <- ergm_proposal(conterms, weights = control$MCMC.prop.weights,
392+
control$MCMC.prop.args, nw, class = proposalclass,
393+
reference = reference, term.options = control$term.options)
392394
}else proposal <- constraints
393395

394396
if (verbose) message(sQuote(paste0(proposal$pkgname,":MH_",proposal$name)),".")
395397

396398
if(!is(obs.constraints, "ergm_proposal")){
397399
if(!is.null(conterms.obs)){
398400
if (verbose) message("Initializing constrained Metropolis-Hastings proposal: ", appendLF=FALSE)
399-
proposal.obs <- ergm_proposal(conterms.obs, hints=control$obs.MCMC.prop, weights=control$obs.MCMC.prop.weights, control$obs.MCMC.prop.args, nw, class=proposalclass, reference=reference, term.options=control$term.options)
401+
proposal.obs <- ergm_proposal(conterms.obs, weights = control$obs.MCMC.prop.weights,
402+
control$obs.MCMC.prop.args, nw, class = proposalclass,
403+
reference = reference, term.options = control$term.options)
400404
}else proposal.obs <- NULL
401405
}else proposal.obs <- obs.constraints
402406

R/ergm.san.R

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,11 +213,13 @@ san.formula <- function(object, response=NULL, reference=~Bernoulli, constraints
213213
if(!is(nw,"ergm_state")) ergm_preprocess_response(nw, response)
214214

215215
# Inherit constraints from nw if needed.
216-
tmp <- .handle.auto.constraints(nw, constraints, NULL, NULL)
216+
tmp <- .handle.auto.constraints(nw, constraints, NULL, NULL, control, "SAN")
217217
nw <- tmp$nw; conterms <- tmp$conterms
218218

219219
if (verbose) message("Initializing unconstrained Metropolis-Hastings proposal: ", appendLF=FALSE)
220-
proposal<-ergm_proposal(conterms,arguments=control$SAN.prop.args,nw=nw, hints=control$SAN.prop, weights=control$SAN.prop.weights, class="c",reference=reference, term.options=control$term.options)
220+
proposal <- ergm_proposal(conterms, arguments = control$SAN.prop.args, nw = nw,
221+
weights = control$SAN.prop.weights, class = "c",
222+
reference = reference, term.options = control$term.options)
221223
if (verbose) message(sQuote(paste0(proposal$pkgname,":MH_",proposal$name)),".")
222224
if (verbose) message("Initializing model...")
223225
model <- ergm_model(formula, nw, extra.aux=list(proposal=proposal$auxiliaries), term.options=control$term.options)
@@ -274,11 +276,13 @@ san.ergm_model <- function(object, reference=~Bernoulli, constraints=~., target.
274276
if(inherits(constraints, "ergm_proposal")) proposal <- constraints
275277
else{
276278
# Inherit constraints from nw if needed.
277-
tmp <- .handle.auto.constraints(nw, constraints, NULL, NULL)
279+
tmp <- .handle.auto.constraints(nw, constraints, NULL, NULL, control, "SAN")
278280
nw <- tmp$nw; conterms <- tmp$conterms
279281
if (verbose) message("Initializing unconstrained Metropolis-Hastings proposal: ", appendLF=FALSE)
280-
proposal <- ergm_proposal(conterms,arguments=control$SAN.prop.args,
281-
nw=nw, hints=control$SAN.prop, weights=control$SAN.prop.weights, class="c",reference=reference, term.options=control$term.options)
282+
proposal <- ergm_proposal(conterms, arguments = control$SAN.prop.args,
283+
nw = nw, weights = control$SAN.prop.weights,
284+
class = "c", reference = reference,
285+
term.options = control$term.options)
282286
if (verbose) message(sQuote(paste0(proposal$pkgname,":MH_",proposal$name)),".")
283287
}
284288

R/ergm_proposal.R

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@
6262
#' `ergm_proposal_table()`, it also sets a call-back to remove all
6363
#' of its proposals from the table should the package be unloaded.
6464
#'
65+
#' @return If called without arguments, a table of the above. Note
66+
#' that `Constraints` column is preprocessed into a [`list`] column
67+
#' with character vector elements `$does` and `$can` and a logical
68+
#' element `$can_any` indicating that it's a meta constraint.
69+
#'
6570
#' @keywords internal
6671
#' @export
6772
ergm_proposal_table <- local({
@@ -156,13 +161,19 @@ prune.ergm_conlist <- function(conlist){
156161
#
157162
########################################################################################
158163

159-
# If the constraints formula is two-sided, add a term .select(LHS) and remove LHS.
160-
.embed_constraint_lhs <- function(formula){
161-
if(length(formula) > 2){
162-
lhs <- try(eval_lhs.formula(formula), silent = TRUE)
163-
if (is(lhs, "try-error") || !is.character(lhs)) stop("Constraint formula must be either one-sided or have a string expression as its LHS.")
164-
nonsimp_update.formula(formula, substitute(~. + .select(..), list(..=lhs)))
165-
}else formula
164+
# If the constraints formula is two-sided, add a term .select(LHS) and
165+
# remove LHS. Also, if the result is a character string, we construct
166+
# a one-sided formula with .select(x). term_lists get passed through.
167+
.embed_constraint_lhs <- function(x) {
168+
if (is(x, "formula")) {
169+
if (length(x) > 2L) {
170+
lhs <- try(eval_lhs.formula(x), silent = TRUE)
171+
if (is(lhs, "try-error") || !is.character(lhs)) stop("Constraint formula must be either one-sided or have a string expression as its LHS.")
172+
nonsimp_update.formula(x, substitute(~. + .select(..), list(.. = lhs)))
173+
} else x
174+
} else if (is.character(x)) as.formula(call("~", call(".select", x)), baseenv())
175+
else if (is(x, "term_list")) x
176+
else stop("Constraint must be either a formula or a string.")
166177
}
167178

168179
.delete_term <- function(tl, terms) discard(tl, ~any(as.character(.)[1] %in% terms))
@@ -283,6 +294,18 @@ ergm_proposal.character <- function(object, arguments, nw, ..., reference=ergm_r
283294
#' @export
284295
ergm_conlist <- function(object, ...) UseMethod("ergm_conlist")
285296

297+
#' @describeIn ergm_conlist list of other eligible inputs: concatenates.
298+
#' @export
299+
ergm_conlist.list <- function(object, ...) {
300+
object <- compact(object)
301+
OK <- map_lgl(object, \(x) is.character(x) || is(x, "formula") || is(x, "term_list"))
302+
if (any(!OK))
303+
stop("Invalid list element(s) ", deparse1(object[!OK]), " passed to ",
304+
sQuote("ergm_conlist()"), "'s ", sQuote("list"), "method.")
305+
306+
object |> ergm_flatten_conterm_list() |> ergm_conlist(...)
307+
}
308+
286309
#' @describeIn ergm_conlist identity method.
287310
#' @export
288311
ergm_conlist.ergm_conlist <- function(object, ...) object
@@ -299,8 +322,12 @@ ergm_conlist.NULL <- function(object, ...) NULL
299322
#' @template term_options
300323
#'
301324
#' @export
302-
ergm_conlist.formula <- function(object, nw, ...)
303-
object |> .embed_constraint_lhs() |> list_rhs.formula() |> ergm_conlist(nw, ...)
325+
ergm_conlist.formula <- function(object, ...)
326+
object |> ergm_flatten_conterm_list() |> ergm_conlist(...)
327+
328+
#' @describeIn ergm_conlist specify the proposal name directly.
329+
#' @export
330+
ergm_conlist.character <- ergm_conlist.formula
304331

305332
#' @describeIn ergm_conlist initialize from [`term_list`].
306333
#' @export
@@ -495,13 +522,10 @@ call.ErgmReference <- function(term, env, nw, ..., term.options=list()){
495522
#' documentation for a similar argument for [ergm()] and see
496523
#' [`ergmConstraint`] for more information.
497524
#' @export
498-
ergm_proposal.formula <- function(object, arguments, nw, hints=trim_env(~sparse), ..., term.options=list()) {
499-
NVL(hints) <- trim_env(~sparse)
500-
525+
ergm_proposal.formula <- function(object, arguments, nw, ..., term.options = list()) {
501526
conlist <- if("constraints" %in% names(arguments))
502527
prune.ergm_conlist(arguments$constraints)
503-
else c(ergm_conlist(object, nw, term.options=term.options, ...),
504-
ergm_conlist(hints, nw, term.options=term.options, ...))
528+
else ergm_conlist(object, nw, term.options = term.options, ...)
505529

506530
## Hand it off to the class ergm_conlist method.
507531
ergm_proposal(conlist, arguments, nw, ..., term.options = term.options)
@@ -621,3 +645,37 @@ free_dyads <- function(con){
621645
else if(is.function(fd)) fd()
622646
else stop("Unsupported free_dyad type; this is probably a programming error.")
623647
}
648+
649+
650+
#' Convert a list of constraint formulas or terms to a flat term list
651+
#'
652+
#' This helper function processes the usual constraint specifications
653+
#' ([`formula`]s, [`term_list`]s, [`character`] strings) or a list
654+
#' thereof into a flat [`term_list`] of distinct values.
655+
#'
656+
#' @param l a [`formula`], a [`term_list`], a [`character`], [`NULL`],
657+
#' or a (potentially heterogeneous) list thereof.
658+
#'
659+
#' @return a [`term_list`].
660+
#'
661+
#' @examples
662+
#' z <- 5
663+
#' ergm_flatten_conterm_list(list(
664+
#' NULL,
665+
#' ~a(x) - b,
666+
#' statnet.common::base_env(~c),
667+
#' NULL,
668+
#' term_list(call("f", z), env = baseenv(), sign = -1)
669+
#' ))
670+
#'
671+
#' @export
672+
ergm_flatten_conterm_list <- function(l) {
673+
enlist(l) |>
674+
compact() |>
675+
map(.embed_constraint_lhs) |>
676+
map_if(\(tlf) is(tlf, "formula"),
677+
list_rhs.formula) |>
678+
do.call(c, args = _) |>
679+
compact() |>
680+
unique()
681+
}

R/obs.constraints.R

Lines changed: 50 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,53 +8,67 @@
88
# Copyright 2003-2026 Statnet Commons
99
################################################################################
1010

11-
.handle.auto.constraints <- function(nw,
12-
constraints=trim_env(~.),
13-
obs.constraints=trim_env(~.-observed),
14-
target.stats=NULL,
15-
default.dot=c("first", "last", "none")){
11+
dot_sub_constraints <- function(nw, ..., default.dot = c("first", "last", "none")) {
1612
default.dot <- match.arg(default.dot)
1713

18-
.preproc_constraints <- function(...){
19-
# Embed the LHS as a .select() constraint.
20-
tll <- list(...) %>% compact() %>% map(.embed_constraint_lhs) %>% map(list_rhs.formula)
21-
# If no missing edges, remove the "observed" constraint.
22-
if(network.naedgecount(nw)==0) tll <- map(tll, .delete_term, "observed")
14+
# Ensure that each argument is a term_list with LHS embedded.
15+
tll <- list(...) |> map(ergm_flatten_conterm_list) |> compact()
16+
# If no missing edges, remove the "observed" constraint.
17+
if (network.naedgecount(nw) == 0L) tll <- map(tll, .delete_term, "observed")
2318

24-
if(length(tll) == 0) return(NULL)
19+
if(all(lengths(tll) == 0L)) return(NULL)
2520

26-
# Go through the constraint lists, substituting the earlier ones into the dots in the later ones.
27-
otl <- tll[[1]]
28-
for(i in seq_along(tll)[-1]){
29-
ntl <- tll[[i]]
21+
# Go through the constraint lists, substituting the earlier ones into the dots in the later ones.
22+
otl <- tll[[1]]
23+
for (i in seq_along(tll)[-1]) {
24+
ntl <- tll[[i]]
3025

31-
# Find the substitution positions.
32-
pos <- which(ntl %>% map_chr(~as.character(.)[1]) == ".")
26+
# Find the substitution positions.
27+
pos <- which(ntl |> map_chr(\(x) as.character(x)[1]) == ".")
3328

34-
# Don't substitute at negative dots.
35-
pos_sign <- attr(ntl, "sign")[pos]
36-
pos <- pos[pos_sign>0]
29+
# Don't substitute at negative dots.
30+
pos_sign <- attr(ntl, "sign")[pos]
31+
pos <- pos[pos_sign>0]
3732

38-
# If no dots, use default behaviour unless there is a negative dot.
39-
if(!length(pos) && all(pos_sign>0))
40-
ntl <- switch(default.dot,
41-
first = c(otl, ntl),
42-
last = c(ntl, otl),
43-
none = ntl)
33+
# If no dots, use default behaviour unless there is a negative dot.
34+
if (!length(pos) && all(pos_sign > 0))
35+
ntl <- switch(default.dot,
36+
first = c(otl, ntl),
37+
last = c(ntl, otl),
38+
none = ntl)
4439

45-
# Substitute (working backwards, to prevent pos from changing).
46-
for(p in rev(pos))
47-
ntl <- c(ntl[seq_len(pos-1)], otl, ntl[-seq_len(pos)])
40+
# Substitute (working backwards, to prevent pos from changing).
41+
for(p in rev(pos))
42+
ntl <- c(ntl[seq_len(pos-1)], otl, ntl[-seq_len(pos)])
4843

49-
otl <- ntl
50-
}
51-
52-
# Delete remaining dots (including the negative ones).
53-
.delete_term(otl, ".")
44+
otl <- ntl
5445
}
5546

56-
tl <- .preproc_constraints(nw%ergmlhs%"constraints", constraints)
57-
obs.tl <- .preproc_constraints(nw%ergmlhs%"obs.constraints", obs.constraints)
47+
# Delete remaining dots (including the negative ones).
48+
.delete_term(otl, ".")
49+
}
50+
51+
52+
.handle.auto.constraints <- function(nw,
53+
constraints=trim_env(~.),
54+
obs.constraints=trim_env(~.-observed),
55+
target.stats=NULL,
56+
control = control.ergm(),
57+
control.prop = "MCMC",
58+
default.dot=c("first", "last", "none")){
59+
default.dot <- match.arg(default.dot)
60+
61+
tl <- dot_sub_constraints(nw,
62+
nw%ergmlhs%"constraints",
63+
c(enlist(constraints),
64+
enlist(control[[paste0(control.prop, ".prop")]])),
65+
default.dot = default.dot)
66+
67+
obs.tl <- dot_sub_constraints(nw,
68+
nw%ergmlhs%"obs.constraints",
69+
c(enlist(obs.constraints),
70+
enlist(control[[paste0("obs.", control.prop, ".prop")]])),
71+
default.dot = default.dot)
5872

5973
# Do any of the observational constraints formulas have terms?
6074
if(length(obs.tl)){

R/simulate.ergm.R

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -319,12 +319,13 @@ simulate_formula <- function(object, ..., basis=eval_lhs.formula(object)) {
319319
if(!is.list(constraints)) constraints <- list(constraints)
320320
constraints <- rep(constraints, length.out=2)
321321
# Inherit constraints from nw if needed.
322-
tmp <- .handle.auto.constraints(nw, constraints[[1]], constraints[[2]], NULL)
322+
tmp <- .handle.auto.constraints(nw, constraints[[1]], constraints[[2]], NULL, control)
323323
nw <- tmp$nw; conterms <- if(observational) tmp$conterms.obs else tmp$conterms
324324

325325
if (verbose) message("Initializing unconstrained Metropolis-Hastings proposal: ", appendLF=FALSE)
326-
proposal <- ergm_proposal(conterms, arguments=if(observational) control$obs.MCMC.prop.args else control$MCMC.prop.args,
327-
nw=nw, hints=if(observational) control$obs.MCMC.prop else control$MCMC.prop, weights=if(observational) control$obs.MCMC.prop.weights else control$MCMC.prop.weights, class="c",reference=reference, term.options=control$term.options)
326+
proposal <- ergm_proposal(conterms, arguments = if(observational) control$obs.MCMC.prop.args else control$MCMC.prop.args,
327+
nw = nw, weights = if (observational) control$obs.MCMC.prop.weights else control$MCMC.prop.weights,
328+
class = "c", reference = reference, term.options = control$term.options)
328329
if (verbose) message(sQuote(paste0(proposal$pkgname,":MH_",proposal$name)),".")
329330
}
330331

@@ -432,12 +433,13 @@ simulate.ergm_model <- function(object, nsim=1, seed=NULL,
432433
if(!is.list(constraints)) constraints <- list(constraints)
433434
constraints <- rep(constraints, length.out=2)
434435
# Inherit constraints from nw if needed.
435-
tmp <- .handle.auto.constraints(nw0, constraints[[1]], constraints[[2]], NULL)
436+
tmp <- .handle.auto.constraints(nw0, constraints[[1]], constraints[[2]], NULL, control)
436437
nw0 <- tmp$nw; conterms <- if(observational) tmp$conterms.obs else tmp$conterms
437438

438439
if (verbose) message("Initializing unconstrained Metropolis-Hastings proposal: ", appendLF=FALSE)
439440
proposal <- ergm_proposal(conterms, arguments=control$MCMC.prop.args,
440-
nw=nw0, hints=control$MCMC.prop, weights=control$MCMC.prop.weights, class="c",reference=reference, term.options=control$term.options)
441+
nw = nw0, weights = control$MCMC.prop.weights, class = "c",
442+
reference = reference, term.options = control$term.options)
441443
if (verbose) message(sQuote(paste0(proposal$pkgname,":MH_",proposal$name)),".")
442444
}
443445

man/control.ergm.Rd

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)