Pair matching
Each timestep, MFNetwork forms new heterosexual partnerships by matching looking women to eligible men. The algorithm that does this is selected by the match_method parameter (default 'closest_age_tapered_seeking'). All matchers share the same inputs and contract; they differ only in how they turn age preferences into pairs.
The matching problem
each timestep
│
▼
eligible women ──► sample desired partner age ──► desired ages ┐
(post-debut, (own age + gap drawn from │
under-partnered, age_diff_pars, by age group ├─► match_method ─► (p1, p2)
"looking") × risk group) │ pairs
│
eligible men ───────────────────────────────────► actual ages ┘
(post-debut, under-partnered) │
▼
add_pairs assigns
type / duration / acts
- Eligible women are post-debut, under-partnered (
partners < concurrency), and pass thep_pair_form“looking” filter. Each draws a desired male partner age — her own age plus an age gap sampled fromage_diff_pars(varies by her age group and risk group; see age mixing). - Eligible men are post-debut and under-partnered, with their actual ages.
- A matcher returns
(p1, p2)— equal-lengthss.uidsarrays of male and female partners — or raisesNoPartnersFoundif it can pair no one. Relationship type (stable/casual/one-time), duration, and coital acts are assigned afterwards byadd_pairs, not by the matcher.
Default: closest_age_tapered_seeking
The default matcher pairs each woman with the closest-aged available man at or above her target, and damps older women’s search so realized age gaps stay faithful to age_diff_pars even when older men are scarce. It runs in four steps.
1. Taper older women’s search. A woman’s chance of looking this step falls with age, reaching zero at f_partnership_taper_cut (default 55):
P(woman looks)
1 ┤●●●●●●●●●●●●●●●●
│ ●●
│ ●●● ∝ ((cut − age) / offset) ^ 1.5
0 ┤────────────────────●●●●●──► age
0 ↑ ↑
taper onset cut (f_partnership_taper_cut)
(cut − offset)
offset is mean_gap + 3·sd, the widest plausible age gap across age/risk groups. Without this taper, older women who draw large gaps cannot find partners, biasing realized gaps downward.
2. Sample desired ages for the women who are looking (own age + gap), and collect eligible men’s actual ages.
3. Closest-age sweep. Sort women by desired age and men by actual age, then sweep once with a two-pointer scan: each woman takes the nearest still-available man near her target (no man is reused). A woman whose nearest free man is more than max_deviation (1 year) off her target is skipped — later, higher-target women may still match.
F desired (sorted): 24 27 33 M actual (sorted): 22 25 28 31 34
24 ─► nearest free man near 24 ─► 25 ✓ gap 1
27 ─► nearest free man near 27 ─► 28 ✓ gap 1
33 ─► nearest free man near 33 ─► 34 ✓ gap 1
22, 31 left unmatched
4. Trim extremes. Drop any pair whose realized age gap exceeds mean_gap + 3·sd, removing unrealistic matches the sweep may have forced.
A small residual downward bias (<1 year) in the female–male age gap remains, driven mainly by debut-age limits truncating the low end of young women’s preferences. Note also that in calibration age_diff_pars is not orthogonal to the concurrency and duration parameters, since those shape the pool of people looking each step.
Other matchers
match_method accepts any of these registry keys (or a callable — see below):
match_method |
How it pairs | Age-gap fidelity |
|---|---|---|
closest_age_tapered_seeking (default) |
sorted closest-age sweep + older-woman taper + extreme-gap trim | highest |
kdtree_nn |
KD-tree nearest-neighbour on male age; contested men go to the closest woman | high |
lsa |
globally optimal assignment over the full age-distance matrix; O(n³), reference only |
optimal (slow) |
greedy_old_enough |
each woman (by ascending target) takes the youngest free man ≥ her target | one-sided |
sort_pair |
sort both groups by age, zip, truncate to the shorter | moderate |
sort_bisect |
sort + trim non-overlapping age tails + subsample (legacy production) | low (boundaries only) |
desired_age_bucket |
1-year desired-age buckets, sample a man within | coarse ⁽¹⁾ |
band_match |
5-year age bands, shuffle and zip within each band | coarse ⁽¹⁾ |
⁽¹⁾ desired_age_bucket and band_match draw from their own RNG seeded per (rand_seed, ti) — reproducible for a fixed run, but not branching-stable under Starsim’s Common Random Numbers (adding an intervention can reshuffle pairings).
Custom matchers
Pass a callable as match_method. It receives the network and returns (p1, p2). Two helpers cover the common setup:
import stisim as sti
from stisim.networks import NoPartnersFound
def my_matcher(net):
f_looking, m_eligible = net._get_eligible() # women: ss.uids; men: bool mask (use m_eligible.uids)
desired = net._sample_desired_ages(f_looking) # target male age per looking woman
... # your pairing logic
if nothing_matched:
raise NoPartnersFound()
return p1, p2 # equal-length male, female ss.uids
net = sti.MFNetwork(match_method=my_matcher)p1 must be male uids and p2 female uids, of equal length. Raise NoPartnersFound when no pairing is possible for the timestep.