couplr

matching two groups into pairs

CRAN status CRAN downloads Monthly downloads R-CMD-check Coverage License: MIT

Optimal one-to-one matching by linear assignment, solved exactly in C++.

Hand couplr two groups and it returns the pairing that minimizes total covariate distance across the whole sample. The assignment is solved exactly on RcppEigen, so the pairing is the global optimum and comes back identical on every run and every machine.

library(couplr)

# match treatment and control on covariates, in a single call
result <- match_couples(treated, control, vars = c("age", "income"), auto_scale = TRUE)

# analysis-ready paired data
join_matched(result, treated, control)

Optimal and deterministic

match_couples() evaluates the pairing as a whole and returns the assignment with the lowest total covariate distance. Because the solve is exact, the total distance is the global minimum, and the same input gives the same pairing every time, with no dependence on row order or a random seed.

When a control pool grows too large to solve exactly, method = "greedy" trades the optimality guarantee for speed while keeping the same scaling, constraints, and blocking. Three strategies cover different memory and speed tradeoffs:

match_couples(treated, control, vars = c("age", "income"), auto_scale = TRUE)   # optimal

match_couples(treated, control, vars = c("age", "income"),
              method = "greedy", strategy = "pq")                               # fast, large pools

Scaling and constraints

Covariates on different scales are put on common footing before distances are computed, and pairs can be constrained or matched within strata:

match_couples(
  treated, control,
  vars         = c("age", "income", "bmi"),
  scale        = "robust",             # or "standardize", "range"; auto_scale = TRUE picks one
  max_distance = 2,                    # forbid pairs beyond this covariate distance
  calipers     = list(age = 5),        # per-variable maximum absolute difference
  block_id     = "site",               # match only within site
  ratio        = 2                     # two controls per treated unit
)

A family of matching designs

The same two-group input drives several established designs, each returning a result the diagnostics and join helpers understand:

# variable-ratio full matching
full_match(treated, control, vars = c("age", "income"), min_controls = 1, max_controls = 3)

# coarsened exact matching
cem_match(treated, control, vars = c("age", "income"))

# balance-constrained cardinality matching
cardinality_match(treated, control, vars = c("age", "income"), max_std_diff = 0.1)

# propensity subclassification and propensity matching, formula interface
subclass_match(treated ~ age + income, data = df, n_subclasses = 5)
ps_match(treated ~ age + income, data = df)

Balance and sensitivity

Check covariate balance before and after matching, and probe how sensitive a conclusion is to unmeasured confounding:

bal <- balance_diagnostics(result)      # standardized differences, variance ratios, KS tests
bal
autoplot(bal)                           # love plot of standardized differences

sensitivity_analysis(result, treated, control, outcome_var = "recovery_days")   # Rosenbaum bounds

Works with the matching ecosystem

A couplr result converts to matchit-class with as_matchit(), so cobalt balance tables and marginaleffects estimates run against it without rewiring the analysis. match_data() returns treatment, weights, and subclass columns in one analysis-ready frame, and autoplot() methods cover matching results, balance, and sensitivity.

m  <- as_matchit(result)
md <- match_data(result)

The assignment backend

lap_solve() exposes the solver layer directly. It takes a cost matrix, handles rectangular shapes and forbidden edges (NA / Inf), and picks from twenty solvers when method = "auto":

cost <- matrix(c(4, 2, 8, 4, 3, 7, 3, 1, 6), nrow = 3, byrow = TRUE)

lap_solve(cost)                      # auto-selected solver
lap_solve(cost, method = "hungarian")
lap_solve_kbest(cost, k = 3)         # the three best assignments (Murty's algorithm)
lap_solve_batch(problems)            # many independent problems in one call

The solvers span the classics and the scaling algorithms: Jonker-Volgenant, Hungarian, Kuhn-Munkres, Bertsekas auction (with epsilon-scaling variants), Goldberg-Kennedy cost-scaling, Gabow-Tarjan bit-scaling, push-relabel, network simplex, and Sinkhorn entropy-regularized transport. bottleneck_assignment() minimizes the largest edge instead of the total, and sinkhorn() returns a soft transport plan.

Watching a solve

pixel_morph() treats two images as source and target pixel sets and morphs one into the other along the optimal assignment, so a matching problem becomes something you can look at:

pixel_morph(imgA, imgB, n_frames = 16)

Installation

install.packages("couplr")            # CRAN

install.packages("pak")               # development version
pak::pak("gcol33/couplr")

Documentation

Support

“Software is like sex: it’s better when it’s free.” — Linus Torvalds

I’m a PhD student who builds R packages in my free time because I believe good tools should be free and open. I started these projects for my own work and figured others might find them useful too.

If this package saved you some time, buying me a coffee is a nice way to say thanks. It helps with my coffee addiction.

Buy Me A Coffee

License

MIT (see the LICENSE file)

Citation

@software{couplr,
  author = {Colling, Gilles},
  title  = {couplr: Optimal Matching via Linear Assignment},
  year   = {2026},
  url    = {https://github.com/gcol33/couplr}
}