Skip to content

Balance race mixing to the target population, check feasibility, seed the imputation, fit every city with MCMLE (#9 to #12) - #13

Merged
smjenness merged 4 commits into
mainfrom
feature/race-mixing-balance
Nov 7, 2026
Merged

smjenness merged 4 commits into
mainfrom
feature/race-mixing-balance

Conversation

@smjenness

@smjenness smjenness commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Closes #9, closes #10, closes #11, closes #12. They touch the same code, so one PR.

#9, race mixing balanced to the target population. Under method = "joint", partner race is now modeled with a multinomial on ego attributes (netparams$<layer>$joint_prace_model). build_netstats() forms the reported endpoint matrix at the synthetic population (rows = ego race, row sums = nodefactor_race) and balances it by symmetric scaling, B = D S D with S the symmetrized reports and D chosen so the row sums stay at nodefactor_race. nodematch_race and nodematch_race_diffF are read from B. The matrices are stored as mixmat_race and mixmat_race_reported.

On the design question: nodefactor holds and nodematch gives. Degree is the ego's own count and anchors edges and every other nodefactor; letting it give would move Seattle's Black main degree by +48%. Plain averaging of the two reports of each cross-race cell is not used because, with the margins held, it drives the Hispanic same-race cell negative in Seattle casual and one-off and in Atlanta casual and one-off. The scaling keeps the homophily odds ratios of the reported matrix and refits the margins, so the targets are always feasible.

Seattle main at 10,000 nodes, default race composition. White egos report 231 Black partners; Black egos hold 174 partnership endpoints in total.

Ego race Black Hispanic White/Other
Black 91.7 7.3 74.5
Hispanic 17.4 75.7 177.4
White/Other 231.1 277.4 3181.7

Worst slack by layer (nodefactor_race - edges - nodematch_race_diffF, feasible iff <= 0): main +157.5 -> -115.6, casual +240.2 -> -207.0, one-off +61.5 -> -16.3. At the four-city project's Seattle race.prop, where only the one-off layer was infeasible, all three layers are feasible too.

The switch to the multinomial also moves joint targets where they were already feasible, because the binomial same-race model read geography as one shared shift in sameness. Atlanta nodematch_race_diffF moves +4.6% (main), +19% (casual), +2% (one-off); the casual shift is the Black-to-Hispanic cell that Atlanta's 4.6% Hispanic population cannot supply. The comparison report is regenerated (253 of 384 cells now shift more than 5%, up from 232).

The random-mixing floor is in as nodematch.race.floor = FALSE on build_netstats(), opt-in, touching nodematch_race_diffF only. It is the stopgap for method = "existing"; under method = "joint" it does not bind.

#10. build_netstats() stops by default when a layer's race targets are infeasible, naming the layer, race and excess; check.feasibility = FALSE returns them anyway. check_race_feasibility() is exported. This is the one legacy-visible behavior change: a method = "existing" Seattle call that used to return unfittable targets now errors.

#11. build_netparams() gains seed. On the pooling question, partner-race missingness by layer in ARTnet.long is 0.7% (main, 18 of 2,618), 2.9% (casual, 175 of 5,978) and 9.9% (one-off, 754 of 7,602). One-off partnerships are the largest group in the pooled table, so the pull on one-off mixing is at most 0.2 percentage points of same-race share; the seed sensitivity you saw is the 10% missingness itself. I left the pooling alone, since stratifying changes legacy draws and would need a snapshot recapture.

#12. fit_comparison_ergms() fits the three template ERGMs with MCMLE for every scenario city under three arms (existing, existing_floor, joint) after running the #10 check on each target set, and render_fit_report() writes inst/validation/method_comparison_fits.md. On the four scenario cities at 5,000 nodes: the three Seattle legacy target sets are skipped by the check, and the other 33 fit and converge, including Seattle under the floor and the joint arm in every city. Static netdx over 1,000 sims gives max |Z| of 3.7 / 4.5 / 4.4 and max |% diff| of 6.7% / 6.7% / 6.3% for existing / floor / joint, the largest in the one-off layer under every arm. One thing to know: ergm opens a one-worker cluster at parallel = 1, so concurrent jobs clash on the port; the harness uses parallel = 0.

Durations under joint_lm. build_netstats(method = "joint") with duration.method = "joint_lm" recomputes the by-age durations at the synthetic population and used netparams$<layer>$durs.<layer>.byage$mean.dur.adj only as a fallback, so a value edited there after build_netparams() was dropped without a word, while the same edit worked under the legacy method. Two changes: build_netstats() gains duration.cap, a single number in time units applied with pmin() to every main and casual duration passed to dissolution_coefs(), under both methods, with a message naming the strata it binds on; and build_netparams() now records the by-age durations it produced under joint_lm, so build_netstats() warns when the column has been changed and points at duration.cap. A cap just below the departure ceiling 1 / (2 * expect.mort) is the intended use (the near-ceiling warning in EpiModel/EpiModel#1138 is the other side of this). Default NULL, no change to legacy output.

Checks: snapshot harness ALL MATCH 3/3 under default args and method = "existing"; 804 test assertions pass; R CMD check 0/0/0. nnet is a new import (a recommended package). Version 2.10.0.

Not done here: stratifying the imputation by layer, and a diff = TRUE feasibility condition for the nodematch_race vector.

… seed the partner-race imputation

Under method = "joint", nodematch_race was standardized on the ego side only, so the partner side was still ARTnet's and the race targets came out infeasible in mostly-White cities. Partner race is now modeled with a multinomial on ego attributes, the reported mixing matrix is built at the synthetic population and symmetrically scaled with nodefactor_race held fixed, and nodematch_race and nodematch_race_diffF are read from the balanced matrix (#9). The opt-in nodematch.race.floor covers the legacy method (#9).

build_netstats() now stops when a layer's race targets cannot be realized by any network, and check_race_feasibility() is exported (#10). build_netparams() gains a seed argument for the partner-race imputation (#11).
fit_comparison_ergms() builds each scenario's targets under three arms (existing, existing with the random-mixing floor, joint), runs check_race_feasibility() on each, fits the three template ERGMs with MCMLE for the feasible ones, and records convergence and static netdx recovery. render_fit_report() writes method_comparison_fits.md. The comparison report is regenerated for the balanced race targets (#12).
…edited by-age duration

Under duration.method = "joint_lm", build_netstats(method = "joint") recomputes the by-age durations at the synthetic population and used the netparams values only as a fallback, so a value edited in netparams$<layer>$durs.<layer>.byage$mean.dur.adj after build_netparams() was dropped without a word. build_netstats() now takes duration.cap, applied with pmin() to every main and casual duration passed to dissolution_coefs() under both methods, and build_netparams() records the by-age durations it produced under joint_lm so build_netstats() can warn when the column has been changed.
@smjenness
smjenness merged commit e6b7654 into main Nov 7, 2026
1 check passed
@smjenness
smjenness deleted the feature/race-mixing-balance branch November 7, 2026 15:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant