| Title: | Optimal Pairing and Matching via Linear Assignment |
|---|---|
| Description: | Solves optimal pairing and matching problems using linear assignment algorithms. Provides implementations of the Hungarian method (Kuhn 1955) <doi:10.1002/nav.3800020109>, Jonker-Volgenant shortest path algorithm (Jonker and Volgenant 1987) <doi:10.1007/BF02278710>, Auction algorithm (Bertsekas 1988) <doi:10.1007/BF02186476>, cost-scaling (Goldberg and Kennedy 1995) <doi:10.1007/BF01585996>, scaling algorithms (Gabow and Tarjan 1989) <doi:10.1137/0218069>, push-relabel (Goldberg and Tarjan 1988) <doi:10.1145/48014.61051>, and Sinkhorn entropy-regularized transport (Cuturi 2013) <doi:10.48550/arxiv.1306.0895>. Designed for matching plots, sites, samples, or any pairwise optimization problem. Supports rectangular matrices, forbidden assignments, data frame inputs, batch solving, k-best solutions, and pixel-level image morphing for visualization. Includes automatic preprocessing with variable health checks, multiple scaling methods (standardized, range, robust), greedy matching algorithms, and comprehensive balance diagnostics for assessing match quality using standardized differences and distribution comparisons. |
| Authors: | Gilles Colling [aut, cre, cph] (ORCID: <https://orcid.org/0000-0003-3070-6066>) |
| Maintainer: | Gilles Colling <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 1.8.1 |
| Built: | 2026-10-10 14:01:12 UTC |
| Source: | https://github.com/gcol33/couplr |
Returns the character vector of method strings for which a trace function
has been registered. Use any of these with lap_animate().
animated_methods()animated_methods()
Character vector of registered method names.
Turns a tidy assignment result back into a 0/1 assignment matrix.
as_assignment_matrix(x, n_sources = NULL, n_targets = NULL)as_assignment_matrix(x, n_sources = NULL, n_targets = NULL)
x |
An assignment result object of class |
n_sources |
Number of source nodes, optional |
n_targets |
Number of target nodes, optional |
Integer matrix with 0 and 1 entries
Constructs a matchit-class S3 object from a couplr result, enabling
use with any function that accepts MatchIt objects (e.g.,
cobalt, marginaleffects).
as_matchit( result, left, right, formula = NULL, left_id = "id", right_id = "id", estimand = NULL, ... )as_matchit( result, left, right, formula = NULL, left_id = "id", right_id = "id", estimand = NULL, ... )
result |
A couplr result object (matching_result, full_matching_result, cem_result, or subclass_result) |
left |
Data frame of left (treated) units |
right |
Data frame of right (control) units |
formula |
Optional formula used for matching. If not provided, a
default formula is constructed from |
left_id |
Name of ID column in left (default: |
right_id |
Name of ID column in right (default: |
estimand |
Target estimand stamped on the matchit object: one of
|
... |
Additional arguments (ignored) |
An S3 object of class "matchit" with fields:
Match matrix (treated x controls)
Named treatment vector (1/0)
Matching weights
Covariate matrix
Original call
Metadata from couplr
## Not run: left <- data.frame(id = 1:5, age = c(25, 35, 45, 55, 65)) right <- data.frame(id = 6:15, age = runif(10, 20, 70)) result <- match_couples(left, right, vars = "age") mi <- as_matchit(result, left, right) # Now use with cobalt: cobalt::bal.tab(mi) ## End(Not run)## Not run: left <- data.frame(id = 1:5, age = c(25, 35, 45, 55, 65)) right <- data.frame(id = 6:15, age = runif(10, 20, 70)) result <- match_couples(left, right, vars = "age") mi <- as_matchit(result, left, right) # Now use with cobalt: cobalt::bal.tab(mi) ## End(Not run)
Solve the linear assignment problem (minimum- or maximum-cost matching)
using several algorithms. Forbidden edges can be marked as NA or Inf.
assignment( cost, maximize = FALSE, method = c("auto", "jv", "hungarian", "munkres", "auction", "auction_gs", "auction_scaled", "sap", "ssp", "sap_dense", "csflow", "hk01", "bruteforce", "ssap_bucket", "cycle_cancel", "gabow_tarjan", "lapmod", "csa", "ramshaw_tarjan", "push_relabel", "network_simplex"), auction_eps = NULL, eps = NULL, memory_mode = "auto", certify = NULL, cardinality = c("complete", "maximum", "fixed"), n_matches = NULL, unmatched_penalty = NULL )assignment( cost, maximize = FALSE, method = c("auto", "jv", "hungarian", "munkres", "auction", "auction_gs", "auction_scaled", "sap", "ssp", "sap_dense", "csflow", "hk01", "bruteforce", "ssap_bucket", "cycle_cancel", "gabow_tarjan", "lapmod", "csa", "ramshaw_tarjan", "push_relabel", "network_simplex"), auction_eps = NULL, eps = NULL, memory_mode = "auto", certify = NULL, cardinality = c("complete", "maximum", "fixed"), n_matches = NULL, unmatched_penalty = NULL )
cost |
Numeric matrix; rows = tasks, columns = agents. |
maximize |
Logical; if |
method |
Character string indicating the algorithm to use. Options: General-purpose solvers:
Auction-based solvers:
Specialized solvers:
Advanced solvers:
One-dimensional problems have their own entry point,
Under
Sparsity and aspect ratio used to divert the choice to Naming a method skips the pass. Rectangular problems are transposed internally so the solver always sees at least as many columns as rows, and the assignment is mapped back afterwards. |
auction_eps |
Optional numeric epsilon for the 'Auction'/'Auction-GS' methods.
If |
eps |
Deprecated. Use |
memory_mode |
One of "auto" (default), "dense", "lazy" or "implicit".
|
certify |
Logical; whether to attach a checked |
cardinality |
How many pairs to produce.
All three are solved exactly by the same solver: the two non-complete modes append dummy columns priced so that the solver's own optimum is the requested objective. |
n_matches |
Integer; the number of pairs to produce. Required when
|
unmatched_penalty |
Numeric; the cost charged for leaving one row
unmatched, under |
method = "auto" selects an algorithm based on problem size and the costs:
Very small (n <= 8 and m <= 8): "bruteforce" — exact enumeration
Binary/constant costs: "hk01" — specialized for 0/1 costs
Otherwise: "jv"
explain_dispatch() reports which rule fired and why. The other solvers are
available by naming them explicitly.
An object of class lap_solve_result, a list with elements:
match — integer vector of length min(nrow(cost), ncol(cost))
giving the assigned column for each row (0 if unassigned).
total_cost — numeric scalar, the objective value.
status — character scalar drawn from solver_status_values(),
computed from what the solver terminated on. "optimal" means the
solver reached its own optimality condition with every row matched;
it is not a checked proof. Use verify_assignment() for that.
method_used — character scalar, the algorithm actually used.
dispatch — list recording how method was chosen: the rule that
fired under "auto", the condition that triggered it, and whether
the method was named explicitly. See explain_dispatch().
certificate — an assignment_certificate, present when one was
checked. See certify.
u, v — row and column duals in the orientation of cost,
present when the solver produced optimal duals for cost: "jv"
(and so "auto" on every problem it sends there), "hungarian",
the lazy "jv" path, and memory_mode = "implicit", where they are
the duals the last restricted master produced. verify_assignment()
reads them instead of solving again. Absent under
cardinality = "maximum" and "fixed", whose solver duals belong to
the padded problem rather than to cost, and for "gabow_tarjan",
whose duals are those of its scaled integer instance.
Under memory_mode = "implicit" the result also carries search: the columns the
first round gave each row (seed_width), the pairs the candidate set ended
up holding (candidate_edges) out of possible_edges, the pairs a cost was
computed for (edges_evaluated), the round count, and rounds, one row per
round of what the master held, what priced out and what each step cost.
"gabow_tarjan" is a bit-scaling algorithm and runs on integer costs. The
conversion is part of the method, so the rule it follows and what it claims
afterwards are both properties of the solve rather than of the caller's
preparation.
Finite costs are shifted so the smallest is zero, multiplied by a scale
factor s, and rounded to the nearest integer. s is set so that
K * s * (max - min) stays at or below 10^13, where K is n + 1 on a
square problem and 2 * min(n, m) + 1 on a rectangular one. K is the
separation the algorithm needs between the optimum and a 1-optimal matching,
and the bound holds every scaled cost clear of the sentinel that marks a
forbidden pair while keeping the path sums the solver forms inside 64-bit
arithmetic. Forbidden pairs take the sentinel and take part in neither the
range nor the conversion.
A matrix whose finite entries are each within 1e-9 of an integer is taken
as an integer matrix: s is one, the shift is an integer, and every cost
reaches the solver as the integer nearest to it. Where those entries are
integers the conversion is exact and the optimum is the optimum of the
instance as supplied; where they are merely near one, each cost moves by at
most 1e-9, so the matching returned costs at most 2 * min(n, m) * 1e-9
more than that optimum. Such a matrix is refused when its range exceeds
1.25 * 10^14 / K, since a scale of one is the only one available and no
choice brings the instance inside the bound; the error names the limit and
points at "jv" or "auction".
Costs that are not integers are solved on the rounded instance. Rounding
moves each cost by at most 1 / (2 * s), so the matching returned costs at
most min(n, m) * K * (max - min) / 10^13 more than the optimum of the
matrix as supplied. On a square problem that is about
n^2 * (max - min) / 10^13, which is 10^-7 of the range at n = 1000 and
10^-5 of it at n = 10000. Duals are divided by s and shifted back, so
they belong to the original matrix; whether the matching is optimal for that
matrix and not only for the rounded one is a question
verify_assignment() answers rather than one this bound settles.
lap_solve() — Tidy interface returning tibbles
lap_solve_kbest() — Find k-best assignments ('Murty' algorithm)
assignment_duals() — Extract dual variables for sensitivity analysis
bottleneck_assignment() — Minimize maximum edge cost (minimax)
sinkhorn() — Entropy-regularized optimal transport
cost <- matrix(c(4,2,5, 3,3,6, 7,5,4), nrow = 3, byrow = TRUE) res <- assignment(cost) res$match; res$total_costcost <- matrix(c(4,2,5, 3,3,6, 7,5,4), nrow = 3, byrow = TRUE) res <- assignment(cost) res$match; res$total_cost
Solves the linear assignment problem and returns dual potentials (u, v) in addition to the optimal matching. The dual variables provide an optimality certificate and enable sensitivity analysis.
assignment_duals(cost, maximize = FALSE, certify = FALSE)assignment_duals(cost, maximize = FALSE, certify = FALSE)
cost |
Numeric matrix; rows = tasks, columns = agents. |
maximize |
Logical; if |
certify |
Logical; if |
The dual variables satisfy the complementary slackness conditions:
For minimization: u[i] + v[j] <= cost[i,j] for all (i,j)
For any assigned pair (i,j): u[i] + v[j] = cost[i,j]
This implies that sum(u) + sum(v) = total_cost (strong duality).
Applications of dual variables:
Optimality verification: Check that duals satisfy constraints
Sensitivity analysis: Reduced cost c[i,j] - u[i] - v[j] shows
how much an edge cost must decrease before it enters the solution
Pricing in column generation: Use duals to price new columns
Warm starting: Reuse duals when costs change slightly
A list with class "assignment_duals_result" containing:
match - integer vector of column assignments (1-based)
total_cost - optimal objective value
u - numeric vector of row dual variables (length n)
v - numeric vector of column dual variables (length m)
status - character, e.g. "optimal"
certificate - an assignment_certificate, present only under
certify = TRUE
assignment() for standard assignment without duals,
verify_assignment() for the check certify = TRUE runs
cost <- matrix(c(4, 2, 5, 3, 3, 6, 7, 5, 4), nrow = 3, byrow = TRUE) result <- assignment_duals(cost) # Check optimality: u + v should equal cost for assigned pairs for (i in 1:3) { j <- result$match[i] cat(sprintf("Row %d -> Col %d: u + v = %.2f, cost = %.2f\n", i, j, result$u[i] + result$v[j], cost[i, j])) } # Verify strong duality cat("sum(u) + sum(v) =", sum(result$u) + sum(result$v), "\n") cat("total_cost =", result$total_cost, "\n") # Reduced costs (how much must cost decrease to enter solution) reduced <- outer(result$u, result$v, "+") reduced_cost <- cost - reduced print(round(reduced_cost, 2))cost <- matrix(c(4, 2, 5, 3, 3, 6, 7, 5, 4), nrow = 3, byrow = TRUE) result <- assignment_duals(cost) # Check optimality: u + v should equal cost for assigned pairs for (i in 1:3) { j <- result$match[i] cat(sprintf("Row %d -> Col %d: u + v = %.2f, cost = %.2f\n", i, j, result$u[i] + result$v[j], cost[i, j])) } # Verify strong duality cat("sum(u) + sum(v) =", sum(result$u) + sum(result$v), "\n") cat("total_cost =", result$total_cost, "\n") # Reduced costs (how much must cost decrease to enter solution) reduced <- outer(result$u, result$v, "+") reduced_cost <- cost - reduced print(round(reduced_cost, 2))
S3 method for augmenting matching results following the broom package
conventions. This is a thin wrapper around join_matched() with
sensible defaults for quick exploration.
## S3 method for class 'matching_result' augment(x, left, right, ...)## S3 method for class 'matching_result' augment(x, left, right, ...)
x |
A matching_result object |
left |
The original left dataset |
right |
The original right dataset |
... |
Additional arguments passed to |
This method follows the augment() convention from the broom package,
making it easy to integrate couplr into tidymodels workflows. It's
equivalent to calling join_matched() with default parameters.
If the broom package is not loaded, you can use couplr::augment()
to access this function.
A tibble with matched pairs and original data (see join_matched())
left <- data.frame( id = 1:5, treatment = 1, age = c(25, 30, 35, 40, 45) ) right <- data.frame( id = 6:10, treatment = 0, age = c(24, 29, 36, 41, 44) ) result <- match_couples(left, right, vars = "age") augment(result, left, right)left <- data.frame( id = 1:5, treatment = 1, age = c(25, 30, 35, 40, 45) ) right <- data.frame( id = 6:10, treatment = 0, age = c(24, 29, 36, 41, 44) ) result <- match_couples(left, right, vars = "age") augment(result, left, right)
Produces ggplot2-based balance assessment plots. Returns a ggplot object.
## S3 method for class 'balance_diagnostics' autoplot( object, type = c("love", "histogram", "variance"), threshold = 0.1, ... )## S3 method for class 'balance_diagnostics' autoplot( object, type = c("love", "histogram", "variance"), threshold = 0.1, ... )
object |
A balance_diagnostics object |
type |
Type of plot: "love" (default), "histogram", or "variance" |
threshold |
Threshold for standardized differences (default: 0.1) |
... |
Additional arguments (ignored) |
A ggplot object
if (requireNamespace("ggplot2", quietly = TRUE)) { set.seed(42) left <- data.frame(id = 1:10, age = rnorm(10, 45, 10), income = rnorm(10, 50000, 15000)) right <- data.frame(id = 11:30, age = rnorm(20, 47, 10), income = rnorm(20, 52000, 15000)) result <- match_couples(left, right, vars = c("age", "income")) bal <- balance_diagnostics(result, left, right, vars = c("age", "income")) ggplot2::autoplot(bal) }if (requireNamespace("ggplot2", quietly = TRUE)) { set.seed(42) left <- data.frame(id = 1:10, age = rnorm(10, 45, 10), income = rnorm(10, 50000, 15000)) right <- data.frame(id = 11:30, age = rnorm(20, 47, 10), income = rnorm(20, 52000, 15000)) result <- match_couples(left, right, vars = c("age", "income")) bal <- balance_diagnostics(result, left, right, vars = c("age", "income")) ggplot2::autoplot(bal) }
Produces ggplot2-based visualizations of matching distance distributions. Returns a ggplot object that can be further customized.
## S3 method for class 'matching_result' autoplot(object, type = c("histogram", "density", "ecdf"), ...)## S3 method for class 'matching_result' autoplot(object, type = c("histogram", "density", "ecdf"), ...)
object |
A matching_result object |
type |
Type of plot: "histogram" (default), "density", or "ecdf" |
... |
Additional arguments (ignored) |
Use plot() for base graphics or autoplot() for ggplot2 output.
The ggplot2 package must be installed.
A ggplot object
if (requireNamespace("ggplot2", quietly = TRUE)) { left <- data.frame(id = 1:5, x = c(1, 2, 3, 4, 5)) right <- data.frame(id = 6:10, x = c(1.1, 2.2, 3.1, 4.2, 5.1)) result <- match_couples(left, right, vars = "x") ggplot2::autoplot(result) ggplot2::autoplot(result, type = "density") }if (requireNamespace("ggplot2", quietly = TRUE)) { left <- data.frame(id = 1:5, x = c(1, 2, 3, 4, 5)) right <- data.frame(id = 6:10, x = c(1.1, 2.2, 3.1, 4.2, 5.1)) result <- match_couples(left, right, vars = "x") ggplot2::autoplot(result) ggplot2::autoplot(result, type = "density") }
Plots p-value upper bounds against sensitivity parameter Gamma.
## S3 method for class 'sensitivity_analysis' autoplot(object, alpha = 0.05, ...)## S3 method for class 'sensitivity_analysis' autoplot(object, alpha = 0.05, ...)
object |
A sensitivity_analysis object |
alpha |
Significance level (default: 0.05) |
... |
Additional arguments (ignored) |
A ggplot object
S3 method enabling cobalt::bal.tab() on couplr result objects.
Requires the cobalt package to be installed.
## S3 method for class 'matching_result' bal.tab(x, left, right, ...) ## S3 method for class 'full_matching_result' bal.tab(x, left, right, ...) ## S3 method for class 'cem_result' bal.tab(x, left, right, ...) ## S3 method for class 'subclass_result' bal.tab(x, data = NULL, ...)## S3 method for class 'matching_result' bal.tab(x, left, right, ...) ## S3 method for class 'full_matching_result' bal.tab(x, left, right, ...) ## S3 method for class 'cem_result' bal.tab(x, left, right, ...) ## S3 method for class 'subclass_result' bal.tab(x, data = NULL, ...)
x |
A couplr result object |
left |
Data frame of left (treated) units |
right |
Data frame of right (control) units |
... |
Additional arguments. Arguments named in |
data |
Data frame used for subclassification (for subclass_result only) |
These methods convert couplr results to the format cobalt expects
(a matchit-class object) and then delegate to cobalt's own
bal.tab.matchit() method. The cobalt package must be
installed but is not required for couplr to function.
A cobalt balance table object
Computes comprehensive balance statistics comparing the distribution of matching variables between left and right units in the matched sample.
balance_diagnostics(result, ...) ## S3 method for class 'matching_result' balance_diagnostics( result, left, right, vars = NULL, left_id = "id", right_id = "id", ... ) ## S3 method for class 'full_matching_result' balance_diagnostics( result, left, right, vars = NULL, left_id = "id", right_id = "id", ... ) ## S3 method for class 'cem_result' balance_diagnostics( result, left, right, vars = NULL, left_id = "id", right_id = "id", ... ) ## S3 method for class 'subclass_result' balance_diagnostics(result, data = NULL, vars = NULL, ...)balance_diagnostics(result, ...) ## S3 method for class 'matching_result' balance_diagnostics( result, left, right, vars = NULL, left_id = "id", right_id = "id", ... ) ## S3 method for class 'full_matching_result' balance_diagnostics( result, left, right, vars = NULL, left_id = "id", right_id = "id", ... ) ## S3 method for class 'cem_result' balance_diagnostics( result, left, right, vars = NULL, left_id = "id", right_id = "id", ... ) ## S3 method for class 'subclass_result' balance_diagnostics(result, data = NULL, vars = NULL, ...)
result |
A matching result object from |
... |
Additional arguments passed to methods |
left |
Data frame of left units |
right |
Data frame of right units |
vars |
Character vector of variable names to check balance for. Defaults to the variables used in matching (if available in result). |
left_id |
Character, name of ID column in left data (default: "id") |
right_id |
Character, name of ID column in right data (default: "id") |
data |
Data frame used for subclassification (when |
This function computes several balance metrics:
Standardized Difference: The difference in means divided by the pooled standard deviation. Values less than 0.1 indicate excellent balance, 0.1-0.25 good balance.
Variance Ratio: The ratio of variances (left/right). Values close to 1 are ideal; the conventional acceptable range is 0.5 to 2. For weighted estimators (full matching, CEM, subclassification) the means and variances are computed using the stratum weights.
KS Statistic: Kolmogorov-Smirnov test statistic comparing distributions. Lower values indicate more similar distributions.
Overall Metrics include mean absolute standardized difference across all variables, proportion of variables with large imbalance (|std diff| > 0.25), and maximum standardized difference.
An S3 object of class balance_diagnostics containing:
Tibble with per-variable balance statistics
List with overall balance metrics
Tibble of matched pairs with variables
Number of matched pairs
Number of unmatched left units
Number of unmatched right units
Matching method used
Whether blocking was used
Per-block statistics (if blocking used)
# Create sample data set.seed(123) left <- data.frame( id = 1:10, age = rnorm(10, 45, 10), income = rnorm(10, 50000, 15000) ) right <- data.frame( id = 11:30, age = rnorm(20, 47, 10), income = rnorm(20, 52000, 15000) ) # Match result <- match_couples(left, right, vars = c("age", "income")) # Get balance diagnostics balance <- balance_diagnostics(result, left, right, vars = c("age", "income")) print(balance) # Get balance table balance_table(balance)# Create sample data set.seed(123) left <- data.frame( id = 1:10, age = rnorm(10, 45, 10), income = rnorm(10, 50000, 15000) ) right <- data.frame( id = 11:30, age = rnorm(20, 47, 10), income = rnorm(20, 52000, 15000) ) # Match result <- match_couples(left, right, vars = c("age", "income")) # Get balance diagnostics balance <- balance_diagnostics(result, left, right, vars = c("age", "income")) print(balance) # Get balance table balance_table(balance)
Formats balance diagnostics into a clean table for display or export.
balance_table(balance, digits = 3)balance_table(balance, digits = 3)
balance |
A balance_diagnostics object from |
digits |
Number of decimal places for rounding (default: 3) |
A tibble with formatted balance statistics
Finds an assignment that minimizes (or maximizes) the maximum edge cost in a perfect matching. Unlike standard LAP which minimizes the sum of costs, BAP minimizes the maximum (bottleneck) cost.
bottleneck_assignment(cost, maximize = FALSE)bottleneck_assignment(cost, maximize = FALSE)
cost |
Numeric matrix; rows = tasks, columns = agents. |
maximize |
Logical; if |
The Bottleneck Assignment Problem (BAP) is a variant of the Linear Assignment Problem where instead of minimizing the sum of assignment costs, we minimize the maximum cost among all assignments (minimax objective).
Algorithm: Uses binary search on the sorted unique costs combined with 'Hopcroft-Karp' bipartite matching to find the minimum threshold that allows a perfect matching.
Complexity: O(E * sqrt(V) * log(unique costs)) where E = edges, V = vertices.
Applications:
Task scheduling with deadline constraints (minimize latest completion)
Resource allocation (minimize maximum load/distance)
Network routing (minimize maximum link utilization)
Fair division problems (minimize maximum disparity)
A list with class "bottleneck_result" containing:
match - integer vector of length nrow(cost) giving the
assigned column for each row (1-based indexing)
bottleneck - numeric scalar, the bottleneck (max/min edge) value
status - character scalar, e.g. "optimal"
assignment() for standard LAP (sum objective), lap_solve() for
tidy LAP interface
# Simple example: minimize max cost cost <- matrix(c(1, 5, 3, 2, 4, 6, 7, 1, 2), nrow = 3, byrow = TRUE) result <- bottleneck_assignment(cost) result$bottleneck # Maximum edge cost in optimal assignment # Maximize minimum (fair allocation) profits <- matrix(c(10, 5, 8, 6, 12, 4, 3, 7, 11), nrow = 3, byrow = TRUE) result <- bottleneck_assignment(profits, maximize = TRUE) result$bottleneck # Minimum profit among all assignments # With forbidden assignments cost <- matrix(c(1, NA, 3, 2, 4, Inf, 5, 1, 2), nrow = 3, byrow = TRUE) result <- bottleneck_assignment(cost)# Simple example: minimize max cost cost <- matrix(c(1, 5, 3, 2, 4, 6, 7, 1, 2), nrow = 3, byrow = TRUE) result <- bottleneck_assignment(cost) result$bottleneck # Maximum edge cost in optimal assignment # Maximize minimum (fair allocation) profits <- matrix(c(10, 5, 8, 6, 12, 4, 3, 7, 11), nrow = 3, byrow = TRUE) result <- bottleneck_assignment(profits, maximize = TRUE) result$bottleneck # Minimum profit among all assignments # With forbidden assignments cost <- matrix(c(1, NA, 3, 2, 4, Inf, 5, 1, 2), nrow = 3, byrow = TRUE) result <- bottleneck_assignment(cost)
Maximizes the number of matched pairs subject to balance constraints, and reports how far the matched sample sits from the largest one the constraints admit.
cardinality_match( left, right, vars, fine = NULL, refined = NULL, refined_exact = 1L, moments = NULL, max_std_diff = Inf, engine = c("auto", "flow", "branch_bound", "heuristic"), distance = "euclidean", weights = NULL, scale = FALSE, auto_scale = FALSE, sigma = NULL, max_distance = Inf, calipers = NULL, left_id = NULL, right_id = NULL, time_limit = 30, node_limit = 500L, method = "auto", max_iter = 100L, batch_fraction = 0.1, memory_mode = "auto" )cardinality_match( left, right, vars, fine = NULL, refined = NULL, refined_exact = 1L, moments = NULL, max_std_diff = Inf, engine = c("auto", "flow", "branch_bound", "heuristic"), distance = "euclidean", weights = NULL, scale = FALSE, auto_scale = FALSE, sigma = NULL, max_distance = Inf, calipers = NULL, left_id = NULL, right_id = NULL, time_limit = 30, node_limit = 500L, method = "auto", max_iter = 100L, batch_fraction = 0.1, memory_mode = "auto" )
left |
Data frame of "left" units, one row each. |
right |
Data frame of "right" units, one row each. |
vars |
Character vector of matching variable names. |
fine |
Exact fine balance on one partition: a character vector of
column names, cross-classified, or a one-sided formula. In a formula |
refined |
A nested hierarchy of groupings, coarsest first: a list, each
element a character vector or one-sided formula, or a character vector as
shorthand for its own sequence of prefixes, so |
refined_exact |
How many of the coarsest levels of |
moments |
Moment constraints beyond |
max_std_diff |
Maximum absolute standardized difference on every
variable in |
engine |
Which solver answers the problem: |
distance |
Distance metric (default: "euclidean"). |
weights |
Optional named vector of variable weights. |
scale |
Scaling method (default: FALSE). |
auto_scale |
If TRUE, check variable health and select scaling automatically (default: FALSE). |
sigma |
Bandwidth for the distance metrics that take one. |
max_distance |
Pairs farther apart than this are not available. |
calipers |
Named vector of per-variable caliper widths. |
left_id, right_id
|
Name of the id column on each side. When absent, ids
come from an |
time_limit |
Seconds the search may run (default: 30). The budget reaches the flow solver, so a solve in flight stops between augmentations rather than running to completion, and the node it belonged to is left unopened so the reported bound still covers the whole tree. |
node_limit |
Nodes the search may open (default: 500). |
method |
LAP solver method for the heuristic's initial match
(default: "auto"). Reaches |
max_iter |
Maximum pruning iterations (default: 100). Reaches
|
batch_fraction |
Fraction of the remaining pairs the heuristic deletes
per iteration (default: 0.1). Reaches |
memory_mode |
One of "auto" (default), "dense" or "implicit". "implicit"
solves the flow and branch and bound engines without building the pair
set: every network solve is taken over
the pairs generated so far, the pairs it omits are priced against its
potentials with the multipliers folded in, and the ones pricing below zero
are added and the solve repeated, so each solve is optimal over every pair.
The distance range the tier weights are built on is read in one pass over
the pairs. The result carries a |
Cardinality matching (Zubizarreta 2012) asks for the largest matched sample that satisfies stated balance constraints, rather than the closest one. The constraints come in two kinds, and which kinds are stated decides how the problem is solved.
Fine and refined covariate balance are representable in the matching network
itself (Rosenbaum, Ross and Silber 2007; Pimentel, Kelz, Silber and
Rosenbaum 2015): every category becomes a node, and a single min-cost flow
solve returns the largest balanced sample together with a dual certificate
of its optimality, at polynomial cost. max_std_diff defaults to Inf,
stating no moment constraint, so a call asking for fine or refined balance
alone takes this path and comes back certified:
cardinality_match(left, right, vars, fine = "region")
Linear moment constraints – a bound on a standardized or mean difference –
cut across the network and are dualized instead. Their multipliers are
updated per node, the Lagrangian value bounds the subtree, and the search
branches on unit inclusion until the bound meets the incumbent or a budget
runs out. A finite max_std_diff states one such constraint per variable
and per direction, so it is what moves a call onto this path, and
node_limit and time_limit are what end it when the bound does not close.
The report says which happened: certified is TRUE only when the search
settled and the gap is zero, and gap says how many matched units separate
the answer from the bound when it is not. Every stopping path returns a
matched set that satisfies every stated constraint, together with a bound
valid for the whole problem.
The certificate carries no tolerance. Each node's bound is the dual objective of its Lagrangian relaxation evaluated in exact arithmetic, which weak duality makes a lower bound whatever the solver returned, and the pruning tests, the constraint values of a matched set and the cardinality read off the bound are decided exactly, over the distances and constraint coefficients as stored.
How long the search runs depends on whether the moment bounds bind. When the
distance-minimizing match already satisfies them, which happens with a loose
bound or with a right pool large enough that the closest partners are
already balanced, the root node certifies and the call returns in
milliseconds. When they bind, every node costs a flow solve, and although
the root enters a feasible incumbent before the first node is opened, the
bound above it closes slowly, so a call can spend its whole budget and still
report a gap. Set node_limit and
time_limit to a budget you are willing to spend, and read stopped_on and
gap to see what the budget bought.
The objective is lexicographic. Matched cardinality comes first, total distance second, so a pair is never added at the cost of a match and never dropped to shorten one.
engine = "heuristic" runs a pruning loop instead: a full match, then
repeated deletion of the pairs carrying the worst variable's imbalance. Its
properties follow from the loop. It starts from a full match and deletes
pairs, and never re-adds one, so it cannot recover from an over-aggressive
deletion. It never maximizes cardinality, it only shrinks a starting match.
It considers one variable per iteration, the current worst, so balance on
the others is incidental. It computes no bound, so it cannot say how far its
answer sits from the largest balanced sample; best_possible and gap are
NA and certified is FALSE.
A matching_result object. Beyond the fields every matching carries
it holds:
cardinality - the report: n_matched, best_possible, gap,
gap_fraction, certified, stopped_on, n_nodes, and the state of
every stated constraint. See print.cardinality_report().
certificate - present when the search certified optimality, with
arithmetic = "exact": the bounds, the comparisons between them and
the matched sample's objective and constraint values it rests on are
all decided exactly.
potentials - the dual potentials of the network solve the
matched sample came from, a list with elements left and right
holding one value per unit, named by id, in distance terms: the
reduced cost of a pair is its distance less the two, with the tier
weights and any moment multipliers already folded in. Under
engine = "flow" that solve is the whole problem, and the potentials
certify the matched sample as its optimum. Under branch and bound it
is the node the incumbent was read from, with the arcs that node fixed
and the moment rows priced at its multipliers, and they certify the
incumbent as that node's optimum; the bound on the whole problem is
cardinality$best_possible. Present when that solve was certified.
status - "optimal", "iteration_limit", or "heuristic".
info$engine - the engine that answered.
info$pruning_iterations and info$pairs_removed are carried by
engine = "heuristic".
Zubizarreta, J. R. (2012). Using mixed integer programming for matching in an observational study of kidney failure after surgery. Journal of the American Statistical Association, 107(500), 1360-1371.
Rosenbaum, P. R., Ross, R. N. and Silber, J. H. (2007). Minimum distance matched sampling with fine balance in an observational study of treatment for ovarian cancer. Journal of the American Statistical Association, 102(477), 75-83.
Pimentel, S. D., Kelz, R. R., Silber, J. H. and Rosenbaum, P. R. (2015). Large, sparse optimal matching with refined covariate balance in an observational study of the health outcomes produced by new surgeons. Journal of the American Statistical Association, 110(510), 515-527.
match_couples() for distance-minimizing matching,
balance_diagnostics() for reading balance off any matched sample.
set.seed(42) left <- data.frame(id = 1:20, x = rnorm(20), y = rnorm(20), region = rep(c("A", "B"), length.out = 20)) right <- data.frame(id = 21:50, x = rnorm(30, 0.5), y = rnorm(30, 0.3), region = rep(c("A", "B"), length.out = 30)) # Exact fine balance on region, no moment constraint: one flow solve, # answered with a certificate. fit <- cardinality_match(left, right, vars = c("x", "y"), fine = "region") fit$cardinality # A standardized-difference bound as well: the same match, searched by # branch and bound under a small node budget. bb <- cardinality_match(left, right, vars = c("x", "y"), fine = "region", max_std_diff = 0.1, node_limit = 25L) bb$cardinalityset.seed(42) left <- data.frame(id = 1:20, x = rnorm(20), y = rnorm(20), region = rep(c("A", "B"), length.out = 20)) right <- data.frame(id = 21:50, x = rnorm(30, 0.5), y = rnorm(30, 0.3), region = rep(c("A", "B"), length.out = 30)) # Exact fine balance on region, no moment constraint: one flow solve, # answered with a certificate. fit <- cardinality_match(left, right, vars = c("x", "y"), fine = "region") fit$cardinality # A standardized-difference bound as well: the same match, searched by # branch and bound under a small node budget. bb <- cardinality_match(left, right, vars = c("x", "y"), fine = "region", max_std_diff = 0.1, node_limit = 25L) bb$cardinality
Coarsens continuous variables into bins, then performs exact matching on the coarsened values. Units in strata containing both left and right units are kept; others are pruned. Matched units receive weights inversely proportional to stratum sizes to maintain balance.
cem_match( left, right, vars, cutpoints = NULL, n_bins = "sturges", grouping = NULL, keep = "all", left_id = "id", right_id = "id" )cem_match( left, right, vars, cutpoints = NULL, n_bins = "sturges", grouping = NULL, keep = "all", left_id = "id", right_id = "id" )
left |
Data frame of left (treated) units |
right |
Data frame of right (control) units |
vars |
Character vector of variable names to coarsen and match on |
cutpoints |
Named list of break vectors per variable. If NULL, automatic binning is used. |
n_bins |
Binning method when |
grouping |
Character vector of variable names to match exactly (without coarsening). These are typically categorical variables. |
keep |
Which units to return: |
left_id |
Name of ID column in left (default: |
right_id |
Name of ID column in right (default: |
CEM algorithm:
Coarsen each numeric variable using cut with
either user-specified breakpoints or automatic binning (Sturges, FD, or
Scott rule)
Categorical variables in grouping are kept as-is
Create strata by concatenating all coarsened values
Drop strata with 0 left or 0 right units
Compute CEM weights: left units get weight 1, right units get
weight n_left_in_stratum / n_right_in_stratum so that the total
weight of right units in each stratum equals the number of left units
An S3 object of class c("cem_result", "couplr_result")
containing:
Tibble with columns id, side, stratum,
weight
Tibble with per-stratum counts
List with n_strata, n_matched_left,
n_matched_right, n_pruned_left, n_pruned_right,
method, vars
set.seed(42) left <- data.frame( id = 1:20, age = rnorm(20, 40, 10), income = rnorm(20, 50000, 10000) ) right <- data.frame( id = 21:60, age = rnorm(40, 42, 10), income = rnorm(40, 52000, 10000) ) result <- cem_match(left, right, vars = c("age", "income")) print(result)set.seed(42) left <- data.frame( id = 1:20, age = rnorm(20, 40, 10), income = rnorm(20, 50000, 10000) ) right <- data.frame( id = 21:60, age = rnorm(40, 42, 10), income = rnorm(40, 52000, 10000) ) result <- cem_match(left, right, vars = c("age", "income")) print(result)
Precomputes a distance matrix between left and right datasets, allowing it to be reused across multiple matching operations with different constraints. This is particularly useful when exploring different matching parameters (max_distance, calipers, methods) without recomputing distances.
compute_distances( left, right, vars, distance = "euclidean", weights = NULL, scale = FALSE, auto_scale = FALSE, left_id = "id", right_id = "id", block_id = NULL, memory_mode = "auto" )compute_distances( left, right, vars, distance = "euclidean", weights = NULL, scale = FALSE, auto_scale = FALSE, left_id = "id", right_id = "id", block_id = NULL, memory_mode = "auto" )
left |
Left dataset (data frame) |
right |
Right dataset (data frame) |
vars |
Character vector of variable names to use for distance computation |
distance |
Distance metric (default: "euclidean") |
weights |
Optional numeric vector of variable weights |
scale |
Scaling method: FALSE, "standardize", "range", or "robust" |
auto_scale |
Apply automatic preprocessing (default: FALSE) |
left_id |
Name of ID column in left (default: "id") |
right_id |
Name of ID column in right (default: "id") |
block_id |
Optional block ID column name for blocked matching |
memory_mode |
One of "auto" (default), "dense", or "lazy". "auto"
warns (or, for a built-in distance metric, switches) when the dense
matrix would consume a large fraction of free system RAM. "lazy" returns
a |
This function computes distances once and stores them in a reusable object.
The resulting distance_object can be passed to match_couples()
(optionally with method = "greedy") instead of providing datasets and
variables.
Benefits:
Performance: Avoid recomputing distances when trying different constraints
Exploration: Quickly test max_distance, calipers, or methods
Consistency: Ensures same distances used across comparisons
With the default memory_mode = "auto"/"dense", the stored cost_matrix
is a plain dense matrix; couplr has no sparse matrix representation, so
forbidden pairs (Inf) still occupy a cell rather than being dropped from
storage. With memory_mode = "lazy", cost_matrix is instead a
lazy_cost_spec that recomputes distances on demand from the underlying
feature data.
The distance_object stores the original datasets, allowing downstream
functions like join_matched() to work seamlessly.
An S3 object of class "distance_object" containing:
cost_matrix: Numeric matrix of distances
left_ids: Character vector of left IDs
right_ids: Character vector of right IDs
block_id: Block ID column name (if specified)
metadata: List with computation details (vars, distance, scale, etc.)
original_left: Original left dataset (for later joining)
original_right: Original right dataset (for later joining)
# Compute distances once left <- data.frame(id = 1:5, age = c(25, 30, 35, 40, 45), income = c(45, 52, 48, 61, 55) * 1000) right <- data.frame(id = 6:10, age = c(24, 29, 36, 41, 44), income = c(46, 51, 47, 60, 54) * 1000) dist_obj <- compute_distances( left, right, vars = c("age", "income"), scale = "standardize" ) # Reuse for different matching strategies result1 <- match_couples(dist_obj, max_distance = 0.5) result2 <- match_couples(dist_obj, max_distance = 1.0) result3 <- match_couples(dist_obj, method = "greedy", strategy = "sorted") # All use the same precomputed distances# Compute distances once left <- data.frame(id = 1:5, age = c(25, 30, 35, 40, 45), income = c(45, 52, 48, 61, 55) * 1000) right <- data.frame(id = 6:10, age = c(24, 29, 36, 41, 44), income = c(46, 51, 47, 60, 54) * 1000) dist_obj <- compute_distances( left, right, vars = c("age", "income"), scale = "standardize" ) # Reuse for different matching strategies result1 <- match_couples(dist_obj, max_distance = 0.5) result2 <- match_couples(dist_obj, max_distance = 1.0) result3 <- match_couples(dist_obj, method = "greedy", strategy = "sorted") # All use the same precomputed distances
Comprehensive diagnostics for a distance matrix with actionable suggestions.
diagnose_distance_matrix( cost_matrix, left = NULL, right = NULL, vars = NULL, warn = TRUE )diagnose_distance_matrix( cost_matrix, left = NULL, right = NULL, vars = NULL, warn = TRUE )
cost_matrix |
Numeric matrix of distances |
left |
Left dataset (for variable checking) |
right |
Right dataset (for variable checking) |
vars |
Variables used for matching |
warn |
If TRUE, issue warnings |
List with diagnostic results and suggestions
What the matrix itself costs a process, which is more than the 8 bytes a cell
occupies: matrix(0, n, m) at the R level is copied into a
lap::CostMatrix (8B data + 4B mask), and the two coexist while garbage
collection lags. Building the matrix and stopping there has peaked at between
1.5 and 1.9 times the raw cell bytes from 5,000 to 20,000 units on the memory
benchmark, so the default multiplier is above what has been measured rather
than fitted to it. n/m
are coerced to double before multiplying so the estimate itself can't
overflow the way lap::CostMatrix's old int flat-index arithmetic did.
estimate_dense_matrix_mb(n, m, overhead_factor = 4)estimate_dense_matrix_mb(n, m, overhead_factor = 4)
n, m
|
Problem dimensions. |
overhead_factor |
Multiplier on the raw cell bytes. |
This is the matrix, not the solve. estimate_dense_solve_mb() is what the
memory guard reads; see there for why the two differ by more than a copy.
Numeric scalar, the estimated footprint of the matrix in megabytes.
estimate_dense_matrix_mb(5000, 5000)estimate_dense_matrix_mb(5000, 5000)
The matrix is the allocation a caller can reason about and it is not what bounds a dense solve. Preparation copies, the solver's own workspace and the assignment structures it carries all sit on top of the matrix, and the peak is what decides whether the solve fits, so that is the quantity the guard compares against available RAM.
estimate_dense_solve_mb(n, m, solve_factor = 12)estimate_dense_solve_mb(n, m, solve_factor = 12)
n, m
|
Problem dimensions. |
solve_factor |
Multiplier on the raw cell bytes. |
The multiplier is read off the measurement rather than from an enumeration of the copies, which has proved to understate it. On the memory benchmark – one fresh R session per arm, peak resident set taken from outside and read against an idle session that loaded the same packages – a dense one-to-one solve has peaked at between 7 and 11 times the raw matrix bytes from 5,000 to 20,000 units, with the figure at a given size moving between runs. The default of 12 sits above every measured peak, which is the direction a guard should err in: it exists to refuse a solve that will not fit, not to predict where the peak lands.
Numeric scalar, the estimated peak footprint of a dense solve in megabytes.
estimate_dense_solve_mb(5000, 5000)estimate_dense_solve_mb(5000, 5000)
Small example datasets for demonstrating couplr functionality across different assignment problem types: square, rectangular, sparse, and binary.
example_costsexample_costs
A list containing four example cost matrices:
A 3x3 cost matrix with costs ranging from 2-7. Optimal assignment: row 1 -> col 2 (cost 2), row 2 -> col 1 (cost 3), row 3 -> col 3 (cost 4). Total optimal cost: 9.
A 3x5 rectangular cost matrix demonstrating assignment when rows < columns. Each of 3 rows is assigned to one of 5 columns; 2 columns remain unassigned. Costs range 1-6.
A 3x3 matrix with NA values indicating forbidden assignments. Use this to test algorithms' handling of constraints. Position (1,3), (2,2), and (3,1) are forbidden.
A 3x3 matrix with binary (0/1) costs, suitable for testing the HK01 algorithm. Diagonal entries are 0 (preferred), off-diagonal entries are 1 (penalty).
These matrices are designed to test different aspects of LAP solvers:
simple_3x3: Basic functionality test. Any correct solver should find total cost = 9.
rectangular_3x5: Tests handling of non-square problems. The optimal solution assigns all 3 rows with minimum total cost.
sparse_with_na: Tests constraint handling. Algorithms must avoid NA positions while finding an optimal assignment among valid entries.
binary_costs: Tests specialized binary cost algorithms. The optimal assignment uses all diagonal entries (total cost = 0).
# Simple 3x3 assignment result <- lap_solve(example_costs$simple_3x3) print(result) # Optimal: sources 1,2,3 -> targets 2,1,3 with cost 9 # Rectangular problem (3 sources, 5 targets) result <- lap_solve(example_costs$rectangular_3x5) print(result) # All 3 sources assigned; 2 targets unassigned # Sparse problem with forbidden assignments result <- lap_solve(example_costs$sparse_with_na) print(result) # Avoids NA positions # Binary costs - test HK01 algorithm result <- lap_solve(example_costs$binary_costs, method = "hk01") print(result) # Finds diagonal assignment (cost = 0)# Simple 3x3 assignment result <- lap_solve(example_costs$simple_3x3) print(result) # Optimal: sources 1,2,3 -> targets 2,1,3 with cost 9 # Rectangular problem (3 sources, 5 targets) result <- lap_solve(example_costs$rectangular_3x5) print(result) # All 3 sources assigned; 2 targets unassigned # Sparse problem with forbidden assignments result <- lap_solve(example_costs$sparse_with_na) print(result) # Avoids NA positions # Binary costs - test HK01 algorithm result <- lap_solve(example_costs$binary_costs, method = "hk01") print(result) # Finds diagonal assignment (cost = 0)
A tidy data frame representation of assignment problems, suitable for use with grouped workflows and batch solving. Contains two independent 3x3 assignment problems in long format.
example_dfexample_df
A tibble with 18 rows and 4 columns:
Simulation/problem identifier. Integer with values 1 or 2,
distinguishing two independent assignment problems. Use with
group_by(sim) for grouped solving.
Source node index. Integer 1-3 representing the row (source) in each 3x3 cost matrix.
Target node index. Integer 1-3 representing the column (target) in each 3x3 cost matrix.
Cost of assigning source to target. Numeric values ranging from 1-7. Each source-target pair has exactly one cost entry.
This dataset demonstrates couplr's data frame interface for LAP solving. The long format (one row per source-target pair) is converted internally to a cost matrix for solving.
Simulation 1: Costs from example_costs$simple_3x3
Optimal assignment: (1->2, 2->1, 3->3)
Total cost: 9
Simulation 2: Different cost structure
Optimal assignment: (1->1, 2->3, 3->3) or equivalent
Total cost: 4
lap_solve, lap_solve_batch,
example_costs
library(dplyr) # Solve both problems with grouped workflow example_df |> group_by(sim) |> lap_solve(source, target, cost) # Batch solving for efficiency example_df |> group_by(sim) |> lap_solve_batch(source, target, cost) # Inspect the data structure example_df |> group_by(sim) |> summarise( n_pairs = n(), min_cost = min(cost), max_cost = max(cost) )library(dplyr) # Solve both problems with grouped workflow example_df |> group_by(sim) |> lap_solve(source, target, cost) # Batch solving for efficiency example_df |> group_by(sim) |> lap_solve_batch(source, target, cost) # Inspect the data structure example_df |> group_by(sim) |> summarise( n_pairs = n(), min_cost = min(cost), max_cost = max(cost) )
method = "auto" selects, and whyassignment(method = "auto") picks a solver from a small set of rules
evaluated against one pass over the cost matrix. This reports the decision
without solving: which rule fired, the property that triggered it, the rules
that were tested first and did not fire, and the internal representation the
problem will be handed to the solver in.
explain_dispatch( cost, method = "auto", maximize = FALSE, cardinality = c("complete", "maximum", "fixed"), n_matches = NULL, unmatched_penalty = NULL ) ## S3 method for class 'dispatch_explanation' print(x, ...)explain_dispatch( cost, method = "auto", maximize = FALSE, cardinality = c("complete", "maximum", "fixed"), n_matches = NULL, unmatched_penalty = NULL ) ## S3 method for class 'dispatch_explanation' print(x, ...)
cost |
Numeric matrix, as passed to |
method |
Character scalar. |
maximize, cardinality, n_matches, unmatched_penalty
|
As in |
x |
A |
... |
Ignored. |
The same rule table drives the dispatch itself, so the report cannot drift from the behaviour.
An object of class dispatch_explanation, a list with elements:
method - the solver that will run.
explicit - TRUE when method was named rather than dispatched.
rule, condition, reason - the rule that fired under "auto".
considered - data frame of every rule tested, in order, with
whether it fired.
n, m, transposed - problem shape, and whether the solver sees
the transpose. Rectangular problems are transposed internally so the
solver always has at least as many columns as rows.
probe - the single-pass summary the rules read.
Invisibly returns x.
explain_dispatch(matrix(runif(400), 20, 20)) explain_dispatch(matrix(sample(0:1, 400, TRUE), 20, 20))explain_dispatch(matrix(runif(400), 20, 20)) explain_dispatch(matrix(sample(0:1, 400, TRUE), 20, 20))
Assigns every unit (left and right) to a matched group with variable ratios (1:k or k:1). Unlike 1:1 matching, full matching does not discard units, producing matched groups where each group contains at least one left and one right unit.
full_match( left, right, vars, distance = "euclidean", min_controls = 1, max_controls = Inf, caliper = NULL, caliper_sd = NULL, weights = NULL, scale = FALSE, auto_scale = FALSE, sigma = NULL, left_id = "id", right_id = "id", method = "optimal", memory_mode = "auto" )full_match( left, right, vars, distance = "euclidean", min_controls = 1, max_controls = Inf, caliper = NULL, caliper_sd = NULL, weights = NULL, scale = FALSE, auto_scale = FALSE, sigma = NULL, left_id = "id", right_id = "id", method = "optimal", memory_mode = "auto" )
left |
Data frame of left (treated) units |
right |
Data frame of right (control) units |
vars |
Character vector of variable names to match on |
distance |
Distance metric: |
min_controls |
Minimum number of right units per group (default: 1) |
max_controls |
Maximum number of right units per group (default: Inf) |
caliper |
Maximum allowable distance for a match. Units with no eligible partner within the caliper are left unmatched. |
caliper_sd |
If not NULL, caliper is expressed in standard deviations of the pooled distance distribution rather than absolute units. |
weights |
Named numeric vector of variable weights |
scale |
Scaling method: |
auto_scale |
If TRUE, automatically preprocess and scale variables |
sigma |
Optional covariance matrix for Mahalanobis distance |
left_id |
Name of ID column in left (default: |
right_id |
Name of ID column in right (default: |
method |
Matching algorithm: |
memory_mode |
One of "auto" (default), "dense" or "implicit". "auto"
warns if the dense cost matrix would consume a large fraction of free
system RAM. "dense" skips the RAM check entirely. "implicit" solves
|
full_match() builds matched groups of variable size. Under the default
min_controls = 1 it solves full matching in the sense of Hansen and
Klopfer (2006): a group holds either one left unit and several right ones or
one right unit and several left ones, and both shapes may appear in the same
solution.
max_controls bounds the many side there, whichever side that is.
A lower bound above one admits only the one-to-many shape, because a group
built around a single right unit holds exactly one of them and cannot meet a
lower bound of two. Every group is then one left unit with between
min_controls and max_controls right ones. Both bounds count right units
whichever side holds more, so an instance with too few right units to give
every left unit that many is refused as "infeasible" rather than answered
with groups counted the other way round.
Two algorithms are available:
Optimal (method = "optimal", default): Solves a min-cost
max-flow problem that minimizes total distance across all group assignments
simultaneously, with the optimum found via Dijkstra's algorithm with Johnson
potentials. At min_controls = 1 the network is an edge cover over the
admissible pairs, which is what lets a group be centred on either side; above
one it is one centre per group and the centres are the left units.
Greedy (method = "greedy"): A fast two-pass heuristic:
Each left unit picks its nearest eligible right unit
Remaining right units are assigned to their nearest already-matched
left unit, respecting max_controls
This is faster but does not guarantee globally optimal results.
Every left unit carries weight 1, and the right units of a group share a
total weight equal to the number of left units in that group, so the two
sides of a group weigh the same. A group holding one left unit and k right
units therefore gives each right unit 1/k; a group holding k left
units and one right unit gives that right unit k. "greedy"
builds one left unit per group and produces only the first shape.
"optimal" at min_controls = 1 centres a group on whichever
side is larger, so both arise; above one the centres are the left units and
the first shape is the only one.
An S3 object of class c("full_matching_result", "couplr_result")
containing:
Tibble with columns group_id, id, side
("left"/"right"), and weight
What the solver terminated on, one of
"optimal" (every unit placed in a group meeting
min_controls, at minimum total distance),
"partial" (groups formed, some units left over, under a
maximum-cardinality-then-minimum-cost objective),
"infeasible" (no group meets the requested bounds), or
"heuristic" (method = "greedy", which neither claims nor
checks optimality). See solver_status_values.
List with n_groups, n_left, n_right,
n_unmatched_left, n_unmatched_right, method,
vars
Left and right IDs that no group contains. Every unit is
either a row of groups or an entry here.
Node potentials from the flow solve, a list with elements
left and right holding one value per unit. They are one
representative of the dual, in the gauge the solver fixes. Present for
method = "optimal" only.
A flow_certificate from
verify_flow, checking the solved flow and its potentials
against the optimality conditions. status says what the solver
terminated on; this says what was checked. Present for
method = "optimal" only. Under memory_mode = "implicit"
the check covers the pairs the flow was solved over, and
omitted_proven_floor bounds the reduced cost of every pair it
omitted; certified_optimal holds both together.
Under memory_mode = "implicit": the seed width, the
pairs generated (candidate_edges) against the pairs there are
(possible_edges), the distances computed
(edges_evaluated) and one row per round.
set.seed(42) left <- data.frame(id = 1:5, age = c(25, 35, 45, 55, 65)) right <- data.frame(id = 6:20, age = runif(15, 20, 70)) result <- full_match(left, right, vars = "age") print(result)set.seed(42) left <- data.frame(id = 1:5, age = c(25, 35, 45, 55, 65)) right <- data.frame(id = 6:20, age = runif(15, 20, 70)) result <- full_match(left, right, vars = "age") print(result)
Extract method used from assignment result
get_method_used(x)get_method_used(x)
x |
An assignment result object |
Character string indicating method used
Extract total cost from assignment result
get_total_cost(x)get_total_cost(x)
x |
An assignment result object |
Numeric total cost
A comprehensive example dataset for demonstrating couplr functionality across vignettes. Contains synthetic hospital staff scheduling data with nurses, shifts, costs, and preference scores suitable for assignment problems, as well as nurse characteristics for matching workflows.
hospital_staffhospital_staff
A list containing eight related datasets:
A 10x10 numeric cost matrix for assigning 10 nurses
to 10 shifts. Values range from approximately 1-15, where lower values
indicate better fit (less overtime, matches skills, respects preferences).
Use with lap_solve() for basic assignment.
A 10x10 numeric preference matrix on a 0-10 scale,
where higher values indicate stronger nurse preference for a shift.
Use with lap_solve(..., maximize = TRUE) to optimize preferences
rather than minimize costs.
A tibble with 100 rows (10 nurses x 10 shifts) in long format for data frame workflows:
Integer 1-10. Unique identifier for each nurse.
Integer 1-10. Unique identifier for each shift.
Numeric. Assignment cost (same values as basic_costs).
Numeric 0-10. Nurse preference score.
Integer 0/1. Binary indicator: 1 if nurse skills match shift requirements, 0 otherwise.
A tibble with 10 rows describing nurse characteristics:
Integer 1-10. Links to schedule_df and basic_costs rows.
Numeric 1-20. Years of nursing experience.
Character. Primary department: "ICU", "ER", "General", or "Pediatrics".
Character. Preferred shift type: "day", "evening", or "night".
Integer 1-3. Certification level where 3 is highest (e.g., 1=RN, 2=BSN, 3=MSN).
A tibble with 10 rows describing shift requirements:
Integer 1-10. Links to schedule_df and basic_costs cols.
Character. Department needing coverage.
Character. Shift type: "day", "evening", or "night".
Numeric. Minimum years of experience required.
Integer 1-3. Minimum certification level.
A tibble for batch solving with 500 rows (5 days x 10 nurses x 10 shifts):
Character. Day of week: "Mon", "Tue", "Wed", "Thu", "Fri".
Integer 1-10. Nurse identifier.
Integer 1-10. Shift identifier.
Numeric. Daily assignment cost (varies by day).
Numeric 0-10. Daily preference score.
Use with group_by(day) for solving each day's schedule.
A tibble with 200 nurses for matching examples, representing a treatment group (e.g., full-time nurses):
Integer 1-200. Unique identifier.
Numeric 22-65. Nurse age in years.
Numeric 0-40. Years of nursing experience.
Numeric 25-75. Hourly wage in dollars.
Character. Primary department assignment.
Integer 1-3. Certification level.
Logical. TRUE for full-time status.
A tibble with 300 potential control nurses (e.g., part-time or registry nurses) for matching. Same structure as nurses_extended. Designed to have systematic differences from nurses_extended (older, less experience on average) to demonstrate matching's ability to create comparable groups.
This dataset is used throughout the couplr documentation to provide a consistent, realistic example that evolves in complexity. It supports three use cases: (1) basic LAP solving with cost matrices, (2) batch solving across multiple days, and (3) matching workflows comparing nurse groups.
The dataset is designed to demonstrate progressively complex scenarios:
Basic LAP (vignette("getting-started")):
basic_costs: Simple 10x10 assignment
preferences: Maximization problem
schedule_df: Data frame input, grouped workflows
weekly_df: Batch solving across days
Algorithm comparison (vignette("algorithms")):
Use basic_costs to compare algorithm behavior
Modify with NA values for sparse scenarios
Matching workflows (vignette("matching-workflows")):
nurses_extended: Treatment group (full-time nurses)
controls_extended: Control pool (part-time/registry nurses)
Match on age, experience, department for causal analysis
lap_solve for basic assignment solving,
lap_solve_batch for batch solving,
match_couples for matching workflows,
vignette("getting-started") for introductory tutorial
# Basic assignment: assign nurses to shifts minimizing cost lap_solve(hospital_staff$basic_costs) # Maximize preferences instead lap_solve(hospital_staff$preferences, maximize = TRUE) # Data frame workflow library(dplyr) hospital_staff$schedule_df |> lap_solve(nurse_id, shift_id, cost) # Batch solve weekly schedule hospital_staff$weekly_df |> group_by(day) |> lap_solve(nurse_id, shift_id, cost) # Matching workflow: match full-time to part-time nurses match_couples( left = hospital_staff$nurses_extended, right = hospital_staff$controls_extended, vars = c("age", "experience_years", "certification_level"), auto_scale = TRUE )# Basic assignment: assign nurses to shifts minimizing cost lap_solve(hospital_staff$basic_costs) # Maximize preferences instead lap_solve(hospital_staff$preferences, maximize = TRUE) # Data frame workflow library(dplyr) hospital_staff$schedule_df |> lap_solve(nurse_id, shift_id, cost) # Batch solve weekly schedule hospital_staff$weekly_df |> group_by(day) |> lap_solve(nurse_id, shift_id, cost) # Matching workflow: match full-time to part-time nurses match_couples( left = hospital_staff$nurses_extended, right = hospital_staff$controls_extended, vars = c("age", "experience_years", "certification_level"), auto_scale = TRUE )
Check if Object is a Distance Object
is_distance_object(x)is_distance_object(x)
x |
Object to check |
Logical: TRUE if x is a distance_object
left <- data.frame(id = 1:3, x = c(1, 2, 3)) right <- data.frame(id = 4:6, x = c(1.1, 2.1, 3.1)) dist_obj <- compute_distances(left, right, vars = "x") is_distance_object(dist_obj) # TRUE is_distance_object(list()) # FALSEleft <- data.frame(id = 1:3, x = c(1, 2, 3)) right <- data.frame(id = 4:6, x = c(1.1, 2.1, 3.1)) dist_obj <- compute_distances(left, right, vars = "x") is_distance_object(dist_obj) # TRUE is_distance_object(list()) # FALSE
Check if object is a batch assignment result
is_lap_solve_batch_result(x)is_lap_solve_batch_result(x)
x |
Object to test |
Logical indicating if x is a batch assignment result
Check if object is a k-best assignment result
is_lap_solve_kbest_result(x)is_lap_solve_kbest_result(x)
x |
Object to test |
Logical indicating if x is a k-best assignment result
Check if object is an assignment result
is_lap_solve_result(x)is_lap_solve_result(x)
x |
Object to test |
Logical indicating if x is an assignment result
Creates an analysis-ready dataset by joining matched pairs with variables from the original left and right datasets. This eliminates the need for manual joins and provides a convenient format for downstream analysis.
join_matched(result, ...) ## S3 method for class 'matching_result' join_matched( result, left, right, left_vars = NULL, right_vars = NULL, left_id = "id", right_id = "id", suffix = c("_left", "_right"), include_distance = TRUE, include_pair_id = TRUE, include_block_id = TRUE, ... ) ## S3 method for class 'full_matching_result' join_matched(result, left, right, left_id = "id", right_id = "id", ...) ## S3 method for class 'cem_result' join_matched(result, left, right, left_id = "id", right_id = "id", ...) ## S3 method for class 'subclass_result' join_matched(result, data = NULL, ...)join_matched(result, ...) ## S3 method for class 'matching_result' join_matched( result, left, right, left_vars = NULL, right_vars = NULL, left_id = "id", right_id = "id", suffix = c("_left", "_right"), include_distance = TRUE, include_pair_id = TRUE, include_block_id = TRUE, ... ) ## S3 method for class 'full_matching_result' join_matched(result, left, right, left_id = "id", right_id = "id", ...) ## S3 method for class 'cem_result' join_matched(result, left, right, left_id = "id", right_id = "id", ...) ## S3 method for class 'subclass_result' join_matched(result, data = NULL, ...)
result |
A result object from |
... |
Additional arguments passed to methods |
left |
The original left dataset |
right |
The original right dataset |
left_vars |
Character vector of variable names to include from left. If NULL (default), includes all variables except the ID column. |
right_vars |
Character vector of variable names to include from right. If NULL (default), includes all variables except the ID column. |
left_id |
Name of the ID column in left dataset (default: "id") |
right_id |
Name of the ID column in right dataset (default: "id") |
suffix |
Character vector of length 2 specifying suffixes for left and right variables (default: c("_left", "_right")) |
include_distance |
Include the matching distance in output (default: TRUE) |
include_pair_id |
Include pair_id column (default: TRUE) |
include_block_id |
Include block_id if blocking was used (default: TRUE) |
data |
Data frame used for subclassification |
This function simplifies the common workflow of joining matched pairs
with original data. Instead of manually merging result$pairs with left
and right datasets, join_matched() handles the joins automatically
and applies consistent naming conventions.
When variables appear in both left and right datasets, suffixes are appended to distinguish them (e.g., "age_left" and "age_right"). This makes it easy to compute differences or use both values in models.
A tibble with one row per matched pair, containing:
pair_id: Sequential pair identifier (if include_pair_id = TRUE)
left_id: ID from left dataset
right_id: ID from right dataset
distance: Matching distance (if include_distance = TRUE)
block_id: Block identifier (if blocking used and include_block_id = TRUE)
Variables from left dataset (with left suffix)
Variables from right dataset (with right suffix)
# Basic usage left <- data.frame( id = 1:5, treatment = 1, age = c(25, 30, 35, 40, 45), income = c(45000, 52000, 48000, 61000, 55000) ) right <- data.frame( id = 6:10, treatment = 0, age = c(24, 29, 36, 41, 44), income = c(46000, 51500, 47500, 60000, 54000) ) result <- match_couples(left, right, vars = c("age", "income")) matched_data <- join_matched(result, left, right) head(matched_data) # Specify which variables to include matched_data <- join_matched( result, left, right, left_vars = c("treatment", "age", "income"), right_vars = c("age", "income"), suffix = c("_treated", "_control") ) # Without distance or pair_id matched_data <- join_matched( result, left, right, include_distance = FALSE, include_pair_id = FALSE )# Basic usage left <- data.frame( id = 1:5, treatment = 1, age = c(25, 30, 35, 40, 45), income = c(45000, 52000, 48000, 61000, 55000) ) right <- data.frame( id = 6:10, treatment = 0, age = c(24, 29, 36, 41, 44), income = c(46000, 51500, 47500, 60000, 54000) ) result <- match_couples(left, right, vars = c("age", "income")) matched_data <- join_matched(result, left, right) head(matched_data) # Specify which variables to include matched_data <- join_matched( result, left, right, left_vars = c("treatment", "age", "income"), right_vars = c("age", "income"), suffix = c("_treated", "_control") ) # Without distance or pair_id matched_data <- join_matched( result, left, right, include_distance = FALSE, include_pair_id = FALSE )
Produce an interactive bipartite-graph animation showing how a chosen linear-assignment algorithm transforms the matching over time. The result is an htmlwidget suitable for use in R Markdown, Quarto, pkgdown vignettes, Shiny apps, or standalone HTML output.
lap_animate( cost, method = "hungarian", maximize = FALSE, width = NULL, height = NULL, elementId = NULL, ... )lap_animate( cost, method = "hungarian", maximize = FALSE, width = NULL, height = NULL, elementId = NULL, ... )
cost |
Numeric cost matrix. Rows = workers/sources, columns = jobs/targets.
|
method |
Character; the algorithm to animate. Must match one of the
methods registered for animation (see |
maximize |
Logical; if |
width, height
|
Optional explicit widget dimensions (pixels or CSS units). |
elementId |
Optional DOM id for the widget container. |
... |
Algorithm-specific extra arguments forwarded to the trace
function (e.g. |
This is a teaching interface. It runs a slower R reference implementation
that emits a state trace at every step, then plays it back in the browser.
For production solving, use assignment() or lap_solve() which call the
fast C++ backends.
An htmlwidget object.
Animation support is added incrementally. Call animated_methods() to see
which method strings currently have a registered trace.
assignment() for production solving, lap_solve() for the tidy
interface.
## Not run: cost <- matrix(c(4, 2, 5, 3, 3, 6, 7, 5, 4), nrow = 3, byrow = TRUE) lap_animate(cost, method = "hungarian") ## End(Not run)## Not run: cost <- matrix(c(4, 2, 5, 3, 3, 6, 7, 5, 4), nrow = 3, byrow = TRUE) lap_animate(cost, method = "hungarian") ## End(Not run)
Provides a tidy interface for solving the linear assignment problem using 'Hungarian' or 'Jonker-Volgenant' algorithms. Supports rectangular matrices, NA/Inf masking, and data frame inputs.
lap_solve( x, source = NULL, target = NULL, cost = NULL, maximize = FALSE, method = "auto", forbidden = NA )lap_solve( x, source = NULL, target = NULL, cost = NULL, maximize = FALSE, method = "auto", forbidden = NA )
x |
Cost matrix, data frame, or tibble. If a data frame/tibble,
must include columns specified by |
source |
Column name for source/row indices (if |
target |
Column name for target/column indices (if |
cost |
Column name for costs (if |
maximize |
Logical; if TRUE, maximizes total cost instead of minimizing (default: FALSE) |
method |
Algorithm to use. One of:
|
forbidden |
Value to mark forbidden assignments (default: NA). Can also use Inf. |
A tibble with columns:
source: row/source indices
target: column/target indices
cost: cost of each assignment
total_cost: total cost (attribute)
# Matrix input cost <- matrix(c(4, 2, 5, 3, 3, 6, 7, 5, 4), nrow = 3) lap_solve(cost) # Data frame input library(dplyr) df <- tibble( source = rep(1:3, each = 3), target = rep(1:3, times = 3), cost = c(4, 2, 5, 3, 3, 6, 7, 5, 4) ) lap_solve(df, source, target, cost) # With NA masking (forbidden assignments) cost[1, 3] <- NA lap_solve(cost) # Grouped data frames df <- tibble( sim = rep(1:2, each = 9), source = rep(1:3, times = 6), target = rep(1:3, each = 3, times = 2), cost = runif(18, 1, 10) ) df |> group_by(sim) |> lap_solve(source, target, cost)# Matrix input cost <- matrix(c(4, 2, 5, 3, 3, 6, 7, 5, 4), nrow = 3) lap_solve(cost) # Data frame input library(dplyr) df <- tibble( source = rep(1:3, each = 3), target = rep(1:3, times = 3), cost = c(4, 2, 5, 3, 3, 6, 7, 5, 4) ) lap_solve(df, source, target, cost) # With NA masking (forbidden assignments) cost[1, 3] <- NA lap_solve(cost) # Grouped data frames df <- tibble( sim = rep(1:2, each = 9), source = rep(1:3, times = 6), target = rep(1:3, each = 3, times = 2), cost = runif(18, 1, 10) ) df |> group_by(sim) |> lap_solve(source, target, cost)
Solve many independent assignment problems at once. Supports lists of matrices,
3D arrays, or grouped data frames. Optional parallel execution via n_threads.
lap_solve_batch( x, source = NULL, target = NULL, cost = NULL, maximize = FALSE, method = "auto", n_threads = 1, forbidden = NA )lap_solve_batch( x, source = NULL, target = NULL, cost = NULL, maximize = FALSE, method = "auto", n_threads = 1, forbidden = NA )
x |
One of: List of cost matrices, 3D array, or grouped data frame |
source |
Column name for source indices (if |
target |
Column name for target indices (if |
cost |
Column name for costs (if |
maximize |
Logical; if TRUE, maximizes total cost (default: FALSE) |
method |
Algorithm to use (default: "auto"). See |
n_threads |
Number of threads for parallel execution (default: 1). Set to NULL to use all available cores. |
forbidden |
Value to mark forbidden assignments (default: NA) |
A tibble with columns:
problem_id: identifier for each problem
source: source indices for assignments
target: target indices for assignments
cost: cost of each assignment
total_cost: total cost for each problem
method_used: algorithm used for each problem
# List of matrices costs <- list( matrix(c(1, 2, 3, 4), 2, 2), matrix(c(5, 6, 7, 8), 2, 2) ) lap_solve_batch(costs) # 3D array arr <- array(runif(2 * 2 * 10), dim = c(2, 2, 10)) lap_solve_batch(arr) # Grouped data frame library(dplyr) df <- tibble( sim = rep(1:5, each = 9), source = rep(1:3, times = 15), target = rep(1:3, each = 3, times = 5), cost = runif(45, 1, 10) ) df |> group_by(sim) |> lap_solve_batch(source, target, cost) # Parallel execution (requires n_threads > 1) lap_solve_batch(costs, n_threads = 2)# List of matrices costs <- list( matrix(c(1, 2, 3, 4), 2, 2), matrix(c(5, 6, 7, 8), 2, 2) ) lap_solve_batch(costs) # 3D array arr <- array(runif(2 * 2 * 10), dim = c(2, 2, 10)) lap_solve_batch(arr) # Grouped data frame library(dplyr) df <- tibble( sim = rep(1:5, each = 9), source = rep(1:3, times = 15), target = rep(1:3, each = 3, times = 5), cost = runif(45, 1, 10) ) df |> group_by(sim) |> lap_solve_batch(source, target, cost) # Parallel execution (requires n_threads > 1) lap_solve_batch(costs, n_threads = 2)
Returns the top k optimal (or near-optimal) assignments using 'Murty' algorithm. Useful for exploring alternative optimal solutions or finding robust assignments.
lap_solve_kbest( x, k = 3, source = NULL, target = NULL, cost = NULL, maximize = FALSE, method = "murty", single_method = "jv", forbidden = NA )lap_solve_kbest( x, k = 3, source = NULL, target = NULL, cost = NULL, maximize = FALSE, method = "murty", single_method = "jv", forbidden = NA )
x |
Cost matrix, data frame, or tibble. If a data frame/tibble,
must include columns specified by |
k |
Number of best solutions to return (default: 3) |
source |
Column name for source/row indices (if |
target |
Column name for target/column indices (if |
cost |
Column name for costs (if |
maximize |
Logical; if TRUE, finds k-best maximizing assignments (default: FALSE) |
method |
Algorithm for each sub-problem (default: "murty"). Future versions may support additional methods. |
single_method |
Algorithm used for solving each node in the search tree (default: "jv") |
forbidden |
Value to mark forbidden assignments (default: NA) |
A tibble with columns:
rank: ranking of solutions (1 = best, 2 = second best, etc.)
solution_id: unique identifier for each solution
source: source indices
target: target indices
cost: cost of each edge in the assignment
total_cost: total cost of the complete solution
# Matrix input - find 5 best solutions cost <- matrix(c(4, 2, 5, 3, 3, 6, 7, 5, 4), nrow = 3) lap_solve_kbest(cost, k = 5) # Data frame input library(dplyr) df <- tibble( source = rep(1:3, each = 3), target = rep(1:3, times = 3), cost = c(4, 2, 5, 3, 3, 6, 7, 5, 4) ) lap_solve_kbest(df, k = 3, source, target, cost) # With maximization lap_solve_kbest(cost, k = 3, maximize = TRUE)# Matrix input - find 5 best solutions cost <- matrix(c(4, 2, 5, 3, 3, 6, 7, 5, 4), nrow = 3) lap_solve_kbest(cost, k = 5) # Data frame input library(dplyr) df <- tibble( source = rep(1:3, each = 3), target = rep(1:3, times = 3), cost = c(4, 2, 5, 3, 3, 6, 7, 5, 4) ) lap_solve_kbest(df, k = 3, source, target, cost) # With maximization lap_solve_kbest(cost, k = 3, maximize = TRUE)
Solves the linear assignment problem when both sources and targets are ordered points on a line. Uses efficient O(n*m) dynamic programming for rectangular problems and O(n) sorting for square problems.
lap_solve_line_metric(x, y, cost = "L1", maximize = FALSE)lap_solve_line_metric(x, y, cost = "L1", maximize = FALSE)
x |
Numeric vector of source positions (will be sorted internally) |
y |
Numeric vector of target positions (will be sorted internally) |
cost |
Cost function for distance. Either:
|
maximize |
Logical; if TRUE, maximizes total cost instead of minimizing (default: FALSE) |
This is a specialized solver that exploits the structure of 1-dimensional assignment problems where costs depend only on the distance between points on a line. It is much faster than general LAP solvers for this special case.
The algorithm works as follows:
Square case (n == m):
Both vectors are sorted and matched in order: x[1] -> y[1], x[2] -> y[2], etc.
This is optimal for any metric cost function on a line.
Rectangular case (n < m): Uses dynamic programming to find the optimal assignment that matches all n sources to a subset of the m targets, minimizing total distance. The DP recurrence is:
dp[i][j] = min(dp[i][j-1], dp[i-1][j-1] + cost(x[i], y[j]))
This finds the minimum cost to match the first i sources to the first j targets.
Complexity:
Time: O(n*m) for rectangular, O(n log n) for square
Space: O(n*m) for DP table
A list with components:
match: Integer vector of length n with 1-based column indices
total_cost: Total cost of the assignment
# Square case: equal number of sources and targets x <- c(1.5, 3.2, 5.1) y <- c(2.0, 3.0, 5.5) result <- lap_solve_line_metric(x, y, cost = "L1") print(result) # Rectangular case: more targets than sources x <- c(1.0, 3.0, 5.0) y <- c(0.5, 2.0, 3.5, 4.5, 6.0) result <- lap_solve_line_metric(x, y, cost = "L2") print(result) # With unsorted inputs (will be sorted internally) x <- c(5.0, 1.0, 3.0) y <- c(4.5, 0.5, 6.0, 2.0, 3.5) result <- lap_solve_line_metric(x, y, cost = "L1") print(result)# Square case: equal number of sources and targets x <- c(1.5, 3.2, 5.1) y <- c(2.0, 3.0, 5.5) result <- lap_solve_line_metric(x, y, cost = "L1") print(result) # Rectangular case: more targets than sources x <- c(1.0, 3.0, 5.0) y <- c(0.5, 2.0, 3.5, 4.5, 6.0) result <- lap_solve_line_metric(x, y, cost = "L2") print(result) # With unsorted inputs (will be sorted internally) x <- c(5.0, 1.0, 3.0) y <- c(4.5, 0.5, 6.0, 2.0, 3.5) result <- lap_solve_line_metric(x, y, cost = "L1") print(result)
Performs one-to-one matching between two datasets. Supports blocking, distance constraints, and various distance metrics.
match_couples( left, right = NULL, vars = NULL, left_id = NULL, right_id = NULL, distance = "euclidean", weights = NULL, scale = FALSE, auto_scale = FALSE, max_distance = Inf, calipers = NULL, block_id = NULL, ignore_blocks = FALSE, require_full_matching = FALSE, method = "auto", strategy = c("row_best", "sorted", "pq"), return_unmatched = TRUE, return_diagnostics = FALSE, parallel = FALSE, replace = FALSE, ratio = 1L, check_costs = TRUE, sigma = NULL, memory_mode = "auto", certify = NULL )match_couples( left, right = NULL, vars = NULL, left_id = NULL, right_id = NULL, distance = "euclidean", weights = NULL, scale = FALSE, auto_scale = FALSE, max_distance = Inf, calipers = NULL, block_id = NULL, ignore_blocks = FALSE, require_full_matching = FALSE, method = "auto", strategy = c("row_best", "sorted", "pq"), return_unmatched = TRUE, return_diagnostics = FALSE, parallel = FALSE, replace = FALSE, ratio = 1L, check_costs = TRUE, sigma = NULL, memory_mode = "auto", certify = NULL )
left |
Data frame of "left" units (e.g., treated, cases) |
right |
Data frame of "right" units (e.g., control, controls) |
vars |
Variable names to use for distance computation |
left_id, right_id
|
Name of the column holding the unit identifier, or
NULL (default) to use a column called |
distance |
Distance metric: "euclidean", "manhattan", "mahalanobis", or a custom function |
weights |
Optional named vector of variable weights |
scale |
Scaling method: FALSE (none), "standardize", "range", or "robust" |
auto_scale |
If TRUE, automatically check variable health and select scaling method (default: FALSE) |
max_distance |
Maximum allowed distance (pairs exceeding this are forbidden) |
calipers |
Named list of per-variable maximum absolute differences |
block_id |
Column name containing block IDs (for stratified matching) |
ignore_blocks |
If TRUE, ignore block_id even if present |
require_full_matching |
If TRUE, error if any units remain unmatched |
method |
Matching method. A LAP solver for optimal matching ("auto",
"hungarian", "jv", "gabow_tarjan", ...), or "greedy" for fast approximate
matching (see |
strategy |
Greedy strategy, used only when
|
return_unmatched |
Include unmatched units in output |
return_diagnostics |
Include detailed diagnostics in output |
parallel |
Enable parallel processing for blocked matching. Requires 'future' and 'future.apply' packages. Can be:
|
replace |
If TRUE, allow matching with replacement (same right unit can be matched to multiple left units). Default: FALSE. |
ratio |
Integer, number of right units to match per left unit. Default: 1 (one-to-one matching). For k:1 matching, set ratio = k. |
check_costs |
If TRUE, check distance distribution for potential problems and provide helpful warnings before matching (default: TRUE) |
sigma |
Optional covariance matrix for Mahalanobis distance. If NULL
(default), the pooled sample covariance is used. Only relevant when
|
memory_mode |
One of "auto" (default), "dense", "lazy" or "implicit".
"auto" warns (or, when |
certify |
Logical; whether the result carries a checked
|
With method set to a LAP solver (the default "auto", or "jv",
"hungarian", ...) it finds the matching that minimizes total distance among
all feasible matchings. With method = "greedy" it uses a fast greedy
strategy (selected by strategy) that does not guarantee the optimal total
distance but scales to very large datasets.
A list with class "matching_result" containing:
pairs: Tibble of matched pairs with distances
unmatched: List of unmatched left and right IDs
info: Matching diagnostics and metadata
status: One of solver_status_values(), computed from what the solve
achieved. "optimal" when every left unit found a partner under an
optimal method, "partial" when constraints left some unmatched,
"heuristic" when a greedy method ran, either because it was asked for
or because the constrained path fell back to it, and "infeasible" when
nothing could be matched.
potentials: The dual potentials of the design's linear program, a list
with elements left and right holding one value per unit, named by
id. The reduced cost of a pair is its distance minus the two
potentials: at least zero on every admissible pair the matching left
out, zero on every pair of a 1:1 matching, and at most zero on the pairs
of a k:1 or with-replacement matching, whose pair arcs carry at most
one unit. A left unit's potential on the k:1 design is the largest of
its replicas'. With replacement the LP separates by row: a left unit's
potential is the distance to the farthest partner it took and every
right unit's is zero. A unit with no admissible partner, or in a block
with none, reads NA. Absent for method = "greedy" and for a
constrained problem answered by maximum cardinality, which is not the
LP the potentials belong to. On the 1:1 design they are the duals
verify_assignment() checks, and a method that returns none has them
computed by assignment_duals().
Under memory_mode = "implicit" it also carries certificate, the checked
statement of optimality (see verify_assignment(), which names the
arithmetic it was decided in), and search: the pairs
the loop generated out of the pairs the problem states, the pairs a cost
was computed for, and one row per round of what each round did. An
infeasible answer carries witness instead, naming the units that could
not be matched and the partners they have between them.
# Basic matching left <- data.frame(id = 1:5, x = c(1, 2, 3, 4, 5), y = c(2, 4, 6, 8, 10)) right <- data.frame(id = 6:10, x = c(1.1, 2.2, 3.1, 4.2, 5.1), y = c(2.1, 4.1, 6.2, 8.1, 10.1)) result <- match_couples(left, right, vars = c("x", "y")) print(result$pairs) # With constraints result <- match_couples(left, right, vars = c("x", "y"), max_distance = 1, calipers = list(x = 0.5)) # With blocking left$region <- c("A", "A", "B", "B", "B") right$region <- c("A", "A", "B", "B", "B") blocks <- matchmaker(left, right, block_type = "group", block_by = "region") result <- match_couples(blocks$left, blocks$right, vars = c("x", "y")) # Fast greedy matching for large datasets result <- match_couples(left, right, vars = c("x", "y"), method = "greedy", strategy = "sorted")# Basic matching left <- data.frame(id = 1:5, x = c(1, 2, 3, 4, 5), y = c(2, 4, 6, 8, 10)) right <- data.frame(id = 6:10, x = c(1.1, 2.2, 3.1, 4.2, 5.1), y = c(2.1, 4.1, 6.2, 8.1, 10.1)) result <- match_couples(left, right, vars = c("x", "y")) print(result$pairs) # With constraints result <- match_couples(left, right, vars = c("x", "y"), max_distance = 1, calipers = list(x = 0.5)) # With blocking left$region <- c("A", "A", "B", "B", "B") right$region <- c("A", "A", "B", "B", "B") blocks <- matchmaker(left, right, block_type = "group", block_by = "region") result <- match_couples(blocks$left, blocks$right, vars = c("x", "y")) # Fast greedy matching for large datasets result <- match_couples(left, right, vars = c("x", "y"), method = "greedy", strategy = "sorted")
A generic function that converts any couplr matching result into a single
analysis-ready data frame with weights, subclass, and
distance columns. This is the couplr equivalent of MatchIt's
match.data().
match_data(result, ...) ## S3 method for class 'matching_result' match_data(result, left, right, left_id = "id", right_id = "id", ...) ## S3 method for class 'full_matching_result' match_data(result, left, right, left_id = "id", right_id = "id", ...) ## S3 method for class 'cem_result' match_data(result, left, right, left_id = "id", right_id = "id", ...) ## S3 method for class 'subclass_result' match_data(result, data = NULL, ...)match_data(result, ...) ## S3 method for class 'matching_result' match_data(result, left, right, left_id = "id", right_id = "id", ...) ## S3 method for class 'full_matching_result' match_data(result, left, right, left_id = "id", right_id = "id", ...) ## S3 method for class 'cem_result' match_data(result, left, right, left_id = "id", right_id = "id", ...) ## S3 method for class 'subclass_result' match_data(result, data = NULL, ...)
result |
A couplr result object (matching_result, full_matching_result, cem_result, or subclass_result) |
... |
Additional arguments passed to methods |
left |
Data frame of left (treated) units |
right |
Data frame of right (control) units |
left_id |
Name of ID column in left (default: |
right_id |
Name of ID column in right (default: |
data |
Data frame containing all units (for CEM and subclassification, left and right are not always needed separately) |
The output format is compatible with downstream packages like cobalt,
WeightIt, and marginaleffects. The stacked (long) format with
treatment and weights columns is the standard layout expected
by these tools.
A tibble with all original variables plus standardized columns:
Unit identifier
1 for left/treated, 0 for right/control
Matching weights
Matched group/stratum identifier
Matching distance (where applicable)
set.seed(42) left <- data.frame(id = 1:5, age = c(25, 35, 45, 55, 65)) right <- data.frame(id = 6:15, age = runif(10, 20, 70)) result <- match_couples(left, right, vars = "age") md <- match_data(result, left, right) head(md)set.seed(42) left <- data.frame(id = 1:5, age = c(25, 35, 45, 55, 65)) right <- data.frame(id = 6:15, age = runif(10, 20, 70)) result <- match_couples(left, right, vars = "age") md <- match_data(result, left, right) head(md)
Solves the matching once for each value of one argument and returns what happened at each: how many units were matched, at what total distance, how balanced the matched sample is, and the certificate saying the matching is optimal for that value. The points are solved as one sequence rather than one at a time, each starting from the matching the previous value found.
match_path( left, right, vars, left_id = NULL, right_id = NULL, vary = "max_distance", values, distance = "euclidean", weights = NULL, scale = FALSE, auto_scale = FALSE, calipers = NULL, sigma = NULL, certify = TRUE, keep_per_row = .implicit_defaults()$keep_per_row, width = .implicit_defaults()$width, tol = .implicit_defaults()$tol, max_rounds = .implicit_defaults()$max_rounds )match_path( left, right, vars, left_id = NULL, right_id = NULL, vary = "max_distance", values, distance = "euclidean", weights = NULL, scale = FALSE, auto_scale = FALSE, calipers = NULL, sigma = NULL, certify = TRUE, keep_per_row = .implicit_defaults()$keep_per_row, width = .implicit_defaults()$width, tol = .implicit_defaults()$tol, max_rounds = .implicit_defaults()$max_rounds )
left, right
|
Data frames to match. Both are required. |
vars |
Character vector of matching variables, present in both. |
left_id, right_id
|
Name of the column holding the unit identifier, or
NULL (default) to use a column called |
vary |
The argument to sweep. |
values |
The values to sweep it through, ascending. |
distance |
Distance metric, as in |
weights, scale, auto_scale, sigma
|
Distance construction, as in
|
calipers |
Per-variable calipers held fixed across the path, as in
|
certify |
Whether each point carries a checked certificate. |
keep_per_row, width, tol, max_rounds
|
The edge-generation loop's search
knobs, shared with |
values must ascend. Each point is solved from the point before it, which
works because a wider value only ever adds pairs to choose from; a descending
sweep takes pairs away from a matching already using them, and is refused
rather than reordered.
An object of class couplr_path: $path, one row per point,
$balance, one row per point per variable, and the match vector,
certificate, round record and Hall witness for each of them.
set.seed(1) left <- data.frame(id = 1:20, x = rnorm(20), y = rnorm(20)) right <- data.frame(id = 1:60, x = rnorm(60), y = rnorm(60)) path <- match_path(left, right, vars = c("x", "y"), vary = "max_distance", values = c(0.5, 1, 2, Inf)) path$path path$balanceset.seed(1) left <- data.frame(id = 1:20, x = rnorm(20), y = rnorm(20)) right <- data.frame(id = 1:60, x = rnorm(60), y = rnorm(60)) path <- match_path(left, right, vars = c("x", "y"), vary = "max_distance", values = c(0.5, 1, 2, Inf)) path$path path$balance
Constructs blocks (strata) for matching, using either grouping variables or clustering algorithms. Returns the input data frames with block IDs assigned, along with block summary statistics.
matchmaker( left, right, block_type = c("none", "group", "cluster"), block_by = NULL, block_vars = NULL, block_method = "kmeans", n_blocks = NULL, min_left = 1, min_right = 1, drop_imbalanced = FALSE, imbalance_threshold = Inf, return_dropped = TRUE, ... )matchmaker( left, right, block_type = c("none", "group", "cluster"), block_by = NULL, block_vars = NULL, block_method = "kmeans", n_blocks = NULL, min_left = 1, min_right = 1, drop_imbalanced = FALSE, imbalance_threshold = Inf, return_dropped = TRUE, ... )
left |
Data frame of "left" units (e.g., treated, cases) |
right |
Data frame of "right" units (e.g., control, controls) |
block_type |
Type of blocking to use:
|
block_by |
Variable name(s) for grouping (if block_type = "group") |
block_vars |
Variable names for clustering (if block_type = "cluster") |
block_method |
Clustering method (if block_type = "cluster"):
|
n_blocks |
Target number of blocks (for clustering) |
min_left |
Minimum number of left units per block |
min_right |
Minimum number of right units per block |
drop_imbalanced |
Drop blocks with extreme imbalance |
imbalance_threshold |
Maximum allowed |n_left - n_right| / max(n_left, n_right) |
return_dropped |
Include dropped blocks in output |
... |
Additional arguments passed to clustering function |
This function does NOT perform matching - it only creates the block structure.
Use match_couples() to perform matching within blocks.
A list with class "matchmaker_result" containing:
left: Left data frame with block_id column added
right: Right data frame with block_id column added
block_summary: Summary statistics for each block
dropped: Information about dropped blocks (if any)
info: Metadata about blocking process
# Group blocking left <- data.frame(id = 1:10, region = rep(c("A", "B"), each = 5), x = rnorm(10)) right <- data.frame(id = 11:20, region = rep(c("A", "B"), each = 5), x = rnorm(10)) blocks <- matchmaker(left, right, block_type = "group", block_by = "region") print(blocks$block_summary) # Clustering blocks <- matchmaker(left, right, block_type = "cluster", block_vars = "x", n_blocks = 3)# Group blocking left <- data.frame(id = 1:10, region = rep(c("A", "B"), each = 5), x = rnorm(10)) right <- data.frame(id = 11:20, region = rep(c("A", "B"), each = 5), x = rnorm(10)) blocks <- matchmaker(left, right, block_type = "group", block_by = "region") print(blocks$block_summary) # Clustering blocks <- matchmaker(left, right, block_type = "cluster", block_vars = "x", n_blocks = 3)
Computes optimal pixel assignment from A to B and returns the final transported frame (without intermediate animation frames).
pixel_morph( imgA, imgB, n_frames = 16L, mode = c("color_walk", "exact", "recursive", "color_match"), lap_method = "jv", maximize = FALSE, quantize_bits = 5L, downscale_steps = 0L, alpha = 1, beta = 0, patch_size = 1L, upscale = 1, show = interactive() )pixel_morph( imgA, imgB, n_frames = 16L, mode = c("color_walk", "exact", "recursive", "color_match"), lap_method = "jv", maximize = FALSE, quantize_bits = 5L, downscale_steps = 0L, alpha = 1, beta = 0, patch_size = 1L, upscale = 1, show = interactive() )
imgA |
Source image (file path or magick image object) |
imgB |
Target image (file path or magick image object) |
n_frames |
Internal parameter for rendering (default: 16) |
mode |
Assignment algorithm: "color_walk" (default), "exact", "recursive", or "color_match" (spatially match pixels of identical quantized colour, filling the remainder by identity) |
lap_method |
LAP solver method (default: "jv") |
maximize |
Logical, maximize instead of minimize cost (default: FALSE) |
quantize_bits |
Color quantization for "color_walk" mode (default: 5) |
downscale_steps |
Number of 2x reductions before computing assignment (default: 0) |
alpha |
Weight for color distance in cost function (default: 1) |
beta |
Weight for spatial distance in cost function (default: 0) |
patch_size |
Tile size for tiled modes (default: 1) |
upscale |
Post-rendering upscaling factor (default: 1) |
show |
Logical, display result in viewer (default: interactive()) |
This function returns a SHARP, pixel-perfect transport of A's pixels to positions determined by the assignment to B.
Key Points:
Assignment computed using: cost = alpha * color_dist + beta * spatial_dist
B's COLORS influence assignment but DO NOT appear in output
Result has A's colors arranged to match B's layout
No motion blur (unlike intermediate frames in animation)
See pixel_morph_animate for detailed explanation of
assignment vs rendering semantics.
Assignment is guaranteed to be a bijection (permutation) ONLY when:
downscale_steps = 0 (no resolution changes)
mode = "exact" with patch_size = 1
With downscaling or tiled modes, assignment may have:
Overlaps: Multiple source pixels map to same destination (last write wins)
Holes: Some destinations never filled (remain transparent)
If assignment is not a bijection (due to downscaling or tiling), a warning will be issued. The result may contain:
Overlapped pixels (multiple sources -> one destination)
Transparent holes (some destinations unfilled)
For guaranteed pixel-perfect results, use:
pixel_morph(A, B, mode = "exact", downscale_steps = 0)
magick image object of the final transported frame
pixel_morph_animate for animated version
if (requireNamespace("magick", quietly = TRUE)) { imgA <- system.file("extdata/icons/circleA_40.png", package = "couplr") imgB <- system.file("extdata/icons/circleB_40.png", package = "couplr") if (nzchar(imgA) && nzchar(imgB)) { result <- pixel_morph(imgA, imgB, n_frames = 4, show = FALSE) } }if (requireNamespace("magick", quietly = TRUE)) { imgA <- system.file("extdata/icons/circleA_40.png", package = "couplr") imgB <- system.file("extdata/icons/circleB_40.png", package = "couplr") if (nzchar(imgA) && nzchar(imgB)) { result <- pixel_morph(imgA, imgB, n_frames = 4, show = FALSE) } }
Creates an animated morph by computing optimal pixel assignment from image A to image B, then rendering intermediate frames showing the transport.
pixel_morph_animate( imgA, imgB, n_frames = 16L, fps = 10L, format = c("gif", "webp", "mp4"), outfile = NULL, show = interactive(), mode = c("color_walk", "exact", "recursive", "color_match"), lap_method = "jv", maximize = FALSE, quantize_bits = 5L, downscale_steps = 0L, alpha = 1, beta = 0, patch_size = 1L, upscale = 1 )pixel_morph_animate( imgA, imgB, n_frames = 16L, fps = 10L, format = c("gif", "webp", "mp4"), outfile = NULL, show = interactive(), mode = c("color_walk", "exact", "recursive", "color_match"), lap_method = "jv", maximize = FALSE, quantize_bits = 5L, downscale_steps = 0L, alpha = 1, beta = 0, patch_size = 1L, upscale = 1 )
imgA |
Source image (file path or magick image object) |
imgB |
Target image (file path or magick image object) |
n_frames |
Integer number of animation frames (default: 16) |
fps |
Frames per second for playback (default: 10) |
format |
Output format: "gif", "webp", or "mp4" |
outfile |
Optional output file path |
show |
Logical, display animation in viewer (default: interactive()) |
mode |
Assignment algorithm: "color_walk" (default), "exact", "recursive", or "color_match" (spatially match pixels of identical quantized colour, filling the remainder by identity) |
lap_method |
LAP solver method (default: "jv") |
maximize |
Logical, maximize instead of minimize cost (default: FALSE) |
quantize_bits |
Color quantization for "color_walk" mode (default: 5) |
downscale_steps |
Number of 2x reductions before computing assignment (default: 0) |
alpha |
Weight for color distance in cost function (default: 1) |
beta |
Weight for spatial distance in cost function (default: 0) |
patch_size |
Tile size for tiled modes (default: 1) |
upscale |
Post-rendering upscaling factor (default: 1) |
CRITICAL: This function has two separate phases with different semantics:
Phase 1 - Assignment Computation:
The assignment is computed by minimizing:
cost(i,j) = alpha * color_distance(A[i], B[j]) +
beta * spatial_distance(pos_i, pos_j)
This means B's COLORS influence which pixels from A map to which positions.
Phase 2 - Rendering (Transport-Only):
The renderer uses ONLY A's colors:
Intermediate frames: A's pixels move along paths with motion blur
Final frame: A's pixels at their assigned positions (sharp, no blur)
B's colors NEVER appear in the output
Result: You get A's colors rearranged to match B's geometry/layout.
B influences WHERE pixels go (via similarity in cost function)
B does NOT determine WHAT COLORS appear in output
Final image has A's palette arranged to mimic B's structure
For pure spatial rearrangement (ignore B's colors in assignment):
pixel_morph_animate(A, B, alpha = 0, beta = 1)
For color-similarity matching (default):
pixel_morph_animate(A, B, alpha = 1, beta = 0)
For hybrid (color + spatial):
pixel_morph_animate(A, B, alpha = 1, beta = 0.2)
Assignment is guaranteed to be a bijection (permutation) ONLY when:
downscale_steps = 0 (no resolution changes)
mode = "exact" with patch_size = 1
With downscaling or tiled modes, assignment may have:
Overlaps: Multiple source pixels map to same destination (last write wins)
Holes: Some destinations never filled (remain transparent)
A warning is issued if overlaps/holes are detected in the final frame.
Invisibly returns a list with animation object and metadata:
animation |
magick animation object |
width |
Image width in pixels |
height |
Image height in pixels |
assignment |
Integer vector of 1-based assignment indices (R convention) |
n_pixels |
Total number of pixels |
mode |
Mode used for matching |
upscale |
Upscaling factor applied |
if (requireNamespace("magick", quietly = TRUE)) { imgA <- system.file("extdata/icons/circleA_40.png", package = "couplr") imgB <- system.file("extdata/icons/circleB_40.png", package = "couplr") if (nzchar(imgA) && nzchar(imgB)) { outfile <- tempfile(fileext = ".gif") pixel_morph_animate(imgA, imgB, outfile = outfile, n_frames = 4, show = FALSE) } }if (requireNamespace("magick", quietly = TRUE)) { imgA <- system.file("extdata/icons/circleA_40.png", package = "couplr") imgB <- system.file("extdata/icons/circleB_40.png", package = "couplr") if (nzchar(imgA) && nzchar(imgB)) { outfile <- tempfile(fileext = ".gif") pixel_morph_animate(imgA, imgB, outfile = outfile, n_frames = 4, show = FALSE) } }
Produces a Love plot (dot plot) of standardized differences.
## S3 method for class 'balance_diagnostics' plot(x, type = c("love", "histogram", "variance"), threshold = 0.1, ...)## S3 method for class 'balance_diagnostics' plot(x, type = c("love", "histogram", "variance"), threshold = 0.1, ...)
x |
A balance_diagnostics object |
type |
Type of plot: "love" (default), "histogram", or "variance" |
threshold |
Threshold line for standardized differences (default: 0.1) |
... |
Additional arguments passed to plotting functions |
The balance_diagnostics object (invisibly)
Produces a histogram of pairwise distances from a matching result.
## S3 method for class 'matching_result' plot(x, type = c("histogram", "density", "ecdf"), ...)## S3 method for class 'matching_result' plot(x, type = c("histogram", "density", "ecdf"), ...)
x |
A matching_result object |
type |
Type of plot: "histogram" (default), "density", or "ecdf" |
... |
Additional arguments passed to plotting functions |
The matching_result object (invisibly)
Plot method for sensitivity analysis (base graphics)
## S3 method for class 'sensitivity_analysis' plot(x, alpha = 0.05, ...)## S3 method for class 'sensitivity_analysis' plot(x, alpha = 0.05, ...)
x |
A sensitivity_analysis object |
alpha |
Significance level (default: 0.05) |
... |
Additional arguments passed to plot |
The sensitivity_analysis object (invisibly)
Main preprocessing function that orchestrates variable health checks, categorical encoding, and automatic scaling selection.
preprocess_matching_vars( left, right, vars, auto_scale = TRUE, scale_method = "auto", check_health = TRUE, remove_problematic = TRUE, verbose = TRUE )preprocess_matching_vars( left, right, vars, auto_scale = TRUE, scale_method = "auto", check_health = TRUE, remove_problematic = TRUE, verbose = TRUE )
left |
Data frame of left units |
right |
Data frame of right units |
vars |
Character vector of variable names |
auto_scale |
Logical, whether to perform automatic preprocessing (default: TRUE) |
scale_method |
Scaling method: "auto", "standardize", "range", "robust", or FALSE |
check_health |
Logical, whether to check variable health (default: TRUE) |
remove_problematic |
Logical, automatically exclude constant/all-NA variables (default: TRUE) |
verbose |
Logical, whether to print warnings (default: TRUE) |
A list with class "preprocessing_result" containing:
left: Preprocessed left data frame
right: Preprocessed right data frame
vars: Final variable names (after exclusions)
health: Variable health diagnostics
scaling_method: Selected scaling method
excluded_vars: Variables that were excluded
warnings: List of warnings issued
Print Method for Balance Diagnostics
## S3 method for class 'balance_diagnostics' print(x, ...)## S3 method for class 'balance_diagnostics' print(x, ...)
x |
A balance_diagnostics object |
... |
Additional arguments (ignored) |
Invisibly returns the input object x.
Print Method for CEM Results
## S3 method for class 'cem_result' print(x, ...)## S3 method for class 'cem_result' print(x, ...)
x |
A cem_result object |
... |
Additional arguments (ignored) |
Invisibly returns the input object x.
Shows how many pairs the whole sweep generated out of how many exist, and then the point table: one row per value, with the matching it found and what finding it cost.
## S3 method for class 'couplr_path' print(x, ...)## S3 method for class 'couplr_path' print(x, ...)
x |
A |
... |
Ignored. |
x, invisibly.
Print Method for Distance Objects
## S3 method for class 'distance_object' print(x, ...)## S3 method for class 'distance_object' print(x, ...)
x |
A distance_object |
... |
Additional arguments (ignored) |
Invisibly returns the input object x.
Print Method for Full Matching Results
## S3 method for class 'full_matching_result' print(x, ...)## S3 method for class 'full_matching_result' print(x, ...)
x |
A full_matching_result object |
... |
Additional arguments (ignored) |
Invisibly returns the input object x.
Prints a summary and the table of results for a batch of assignment
problems solved with lap_solve_batch().
## S3 method for class 'lap_solve_batch_result' print(x, ...)## S3 method for class 'lap_solve_batch_result' print(x, ...)
x |
A |
... |
Additional arguments passed to |
Invisibly returns the input object x.
Print method for k-best assignment results
## S3 method for class 'lap_solve_kbest_result' print(x, ...)## S3 method for class 'lap_solve_kbest_result' print(x, ...)
x |
A |
... |
Additional arguments passed to |
Invisibly returns the input object x.
Nicely prints a lap_solve_result object, including the assignments,
total cost, and method used.
## S3 method for class 'lap_solve_result' print(x, ...)## S3 method for class 'lap_solve_result' print(x, ...)
x |
A |
... |
Additional arguments passed to |
Invisibly returns the input object x.
Print method for matching results
## S3 method for class 'matching_result' print(x, ...)## S3 method for class 'matching_result' print(x, ...)
x |
A matching_result object |
... |
Additional arguments (ignored) |
Invisibly returns the input object x.
Print method for matchmaker results
## S3 method for class 'matchmaker_result' print(x, ...)## S3 method for class 'matchmaker_result' print(x, ...)
x |
A matchmaker_result object |
... |
Additional arguments (ignored) |
Invisibly returns the input object x.
Print method for preprocessing result
## S3 method for class 'preprocessing_result' print(x, ...)## S3 method for class 'preprocessing_result' print(x, ...)
x |
A preprocessing_result object |
... |
Additional arguments (ignored) |
Invisibly returns the input object x.
Print method for sensitivity analysis
## S3 method for class 'sensitivity_analysis' print(x, ...)## S3 method for class 'sensitivity_analysis' print(x, ...)
x |
A sensitivity_analysis object |
... |
Additional arguments (ignored) |
Invisibly returns the input object x.
Print Method for Subclassification Results
## S3 method for class 'subclass_result' print(x, ...)## S3 method for class 'subclass_result' print(x, ...)
x |
A subclass_result object |
... |
Additional arguments (ignored) |
Invisibly returns the input object x.
Print method for variable health
## S3 method for class 'variable_health' print(x, ...)## S3 method for class 'variable_health' print(x, ...)
x |
A variable_health object |
... |
Additional arguments (ignored) |
Invisibly returns the input object x.
Matches treated and control units based on estimated propensity scores.
Fits a logistic regression model (or accepts a pre-fitted one), computes
logit propensity scores, and calls match_couples() with a caliper on
the logit scale.
ps_match( formula = NULL, data = NULL, treatment = NULL, ps_model = NULL, caliper_sd = 0.2, method = "auto", replace = FALSE, ratio = 1L, ... )ps_match( formula = NULL, data = NULL, treatment = NULL, ps_model = NULL, caliper_sd = 0.2, method = "auto", replace = FALSE, ratio = 1L, ... )
formula |
Formula for propensity score model (treatment ~ covariates).
Required if |
data |
Combined dataset containing both treated and control units |
treatment |
Name of the binary treatment column (0/1 or logical) |
ps_model |
Pre-fitted |
caliper_sd |
Caliper width in standard deviations of logit(PS). Default: 0.2 (Rosenbaum and Rubin recommendation). |
method |
LAP solver method (default: "auto") |
replace |
If TRUE, match with replacement (default: FALSE) |
ratio |
Integer k for k:1 matching (default: 1) |
... |
Additional arguments passed to |
The propensity score is the probability of treatment assignment conditional on observed covariates. Matching is performed on the logit of the propensity score (Rosenbaum and Rubin 1985), which provides better distributional properties than matching on the raw probability scale.
The default caliper of 0.2 SD of logit(PS) is recommended by Austin (2011) as removing approximately 98% of bias.
A matching_result object with additional propensity score info
in result$info$ps_model and result$info$caliper_value.
set.seed(42) n <- 100 data <- data.frame( id = seq_len(n), treated = rbinom(n, 1, 0.4), age = rnorm(n, 50, 10), income = rnorm(n, 50000, 15000) ) result <- ps_match(treated ~ age + income, data = data, treatment = "treated") print(result)set.seed(42) n <- 100 data <- data.frame( id = seq_len(n), treated = rbinom(n, 1, 0.4), age = rnorm(n, 50, 10), income = rnorm(n, 50000, 15000) ) result <- ps_match(treated ~ age + income, data = data, treatment = "treated") print(result)
Assesses how sensitive a matched comparison is to hidden bias using Rosenbaum bounds on the Wilcoxon signed-rank statistic.
sensitivity_analysis( result, left, right, outcome_var, gamma = seq(1, 3, by = 0.25), alternative = c("greater", "less", "two.sided"), left_id = "id", right_id = "id" )sensitivity_analysis( result, left, right, outcome_var, gamma = seq(1, 3, by = 0.25), alternative = c("greater", "less", "two.sided"), left_id = "id", right_id = "id" )
result |
A matching_result object from |
left |
Original left (treated) dataset |
right |
Original right (control) dataset |
outcome_var |
Name of the outcome column in |
gamma |
Numeric vector of sensitivity parameters (default: seq(1, 3, by = 0.25)). Gamma = 1 means no hidden bias. |
alternative |
Direction of the test: "greater" (default), "less", or "two.sided" |
left_id |
Name of ID column in left (default: "id") |
right_id |
Name of ID column in right (default: "id") |
Rosenbaum (2002, Chapter 4) bounds quantify how much hidden bias (an unobserved confounder) would be needed to explain away the observed treatment effect. The sensitivity parameter Gamma represents the maximum ratio of treatment odds between two matched units:
Gamma = 1: No hidden bias (standard Wilcoxon test)
Gamma = 2: One unit could be twice as likely to receive treatment due to an unobserved factor
The function computes upper and lower bounds on the p-value of the Wilcoxon signed-rank test under each level of hidden bias. A finding is "insensitive to bias" if p_upper remains below 0.05 even at large Gamma.
An S3 object of class sensitivity_analysis containing:
Tibble with columns: gamma, t_stat, p_upper, p_lower
Number of matched pairs analyzed
Smallest gamma at which p_upper > 0.05
Direction of test
Rosenbaum, P.R. (2002). Observational Studies, 2nd edition. Springer.
set.seed(42) left <- data.frame(id = 1:20, x = rnorm(20), outcome = rnorm(20, 1, 1)) right <- data.frame(id = 21:40, x = rnorm(20), outcome = rnorm(20, 0, 1)) result <- match_couples(left, right, vars = "x") sens <- sensitivity_analysis(result, left, right, outcome_var = "outcome") print(sens)set.seed(42) left <- data.frame(id = 1:20, x = rnorm(20), outcome = rnorm(20, 1, 1)) right <- data.frame(id = 21:40, x = rnorm(20), outcome = rnorm(20, 0, 1)) result <- match_couples(left, right, vars = "x") sens <- sensitivity_analysis(result, left, right, outcome_var = "outcome") print(sens)
Compute an entropy-regularized optimal transport plan using the 'Sinkhorn-Knopp' algorithm. Unlike other LAP solvers that return a hard 1-to-1 assignment, this returns a soft assignment (doubly stochastic matrix).
sinkhorn( cost, lambda = 10, tol = 1e-09, max_iter = 1000, r_weights = NULL, c_weights = NULL )sinkhorn( cost, lambda = 10, tol = 1e-09, max_iter = 1000, r_weights = NULL, c_weights = NULL )
cost |
Numeric matrix of transport costs. |
lambda |
Regularization parameter (default 10). Higher values produce sharper (more deterministic) transport plans; lower values produce smoother distributions. Typical range: 1-100. |
tol |
Convergence tolerance (default 1e-9). |
max_iter |
Maximum iterations (default 1000). |
r_weights |
Optional numeric vector of row marginals (source distribution). Default is uniform. Will be normalized to sum to 1. |
c_weights |
Optional numeric vector of column marginals (target distribution). Default is uniform. Will be normalized to sum to 1. |
The 'Sinkhorn-Knopp' algorithm solves the entropy-regularized optimal transport problem:
subject to row sums = r_weights and column sums = c_weights.
The entropy term H(P) encourages spread in the transport plan. As lambda -> Inf, the solution approaches the standard (unregularized) optimal transport.
Key differences from standard LAP solvers:
Returns a soft assignment (probabilities) not a hard 1-to-1 matching
Supports unequal marginals (weighted distributions)
Differentiable, making it useful in ML pipelines
Very fast: O(n^2) per iteration with typically O(1/tol^2) iterations
Use sinkhorn_to_assignment() to round the soft assignment to a hard matching.
A list with elements:
transport_plan — numeric matrix, the optimal transport plan P.
Row sums approximate r_weights, column sums approximate c_weights.
cost — the transport cost <C, P> (without entropy term).
u, v — scaling vectors (P = diag(u) * K * diag(v) where K = exp(-lambda*C)).
converged — logical, whether the algorithm converged.
iterations — number of iterations used.
lambda — the regularization parameter used.
Cuturi, M. (2013). 'Sinkhorn Distances': Lightspeed Computation of Optimal Transport. Advances in Neural Information Processing Systems, 26.
assignment() for hard 1-to-1 matching, sinkhorn_to_assignment()
to round soft assignments.
cost <- matrix(c(1, 2, 3, 4, 5, 6, 7, 8, 9), nrow = 3, byrow = TRUE) # Soft assignment with default parameters result <- sinkhorn(cost) print(round(result$transport_plan, 3)) # Sharper assignment (higher lambda) result_sharp <- sinkhorn(cost, lambda = 50) print(round(result_sharp$transport_plan, 3)) # With custom marginals (more mass from row 1) result_weighted <- sinkhorn(cost, r_weights = c(0.5, 0.25, 0.25)) print(round(result_weighted$transport_plan, 3)) # Round to hard assignment hard_match <- sinkhorn_to_assignment(result) print(hard_match)cost <- matrix(c(1, 2, 3, 4, 5, 6, 7, 8, 9), nrow = 3, byrow = TRUE) # Soft assignment with default parameters result <- sinkhorn(cost) print(round(result$transport_plan, 3)) # Sharper assignment (higher lambda) result_sharp <- sinkhorn(cost, lambda = 50) print(round(result_sharp$transport_plan, 3)) # With custom marginals (more mass from row 1) result_weighted <- sinkhorn(cost, r_weights = c(0.5, 0.25, 0.25)) print(round(result_weighted$transport_plan, 3)) # Round to hard assignment hard_match <- sinkhorn_to_assignment(result) print(hard_match)
Convert a soft transport plan from sinkhorn() to a hard 1-to-1 assignment
using greedy rounding.
sinkhorn_to_assignment(result)sinkhorn_to_assignment(result)
result |
Either a result from |
Greedy rounding iteratively assigns each row to its most probable column,
ensuring no column is assigned twice. This may not give the globally optimal
hard assignment; for that, use the transport plan as a cost matrix with
assignment().
Integer vector of column assignments (1-based), same format as
assignment().
cost <- matrix(c(1, 2, 3, 4, 5, 6, 7, 8, 9), nrow = 3, byrow = TRUE) result <- sinkhorn(cost, lambda = 20) hard_match <- sinkhorn_to_assignment(result) print(hard_match)cost <- matrix(c(1, 2, 3, 4, 5, 6, 7, 8, 9), nrow = 3, byrow = TRUE) result <- sinkhorn(cost, lambda = 20) hard_match <- sinkhorn_to_assignment(result) print(hard_match)
The closed set of values a couplr solve result's status field can take.
solver_status_values()solver_status_values()
"optimal"The solver terminated on its own optimality condition and every row it was asked to match is matched.
"partial"Feasible, but fewer rows are matched than requested, under a maximum-cardinality-then-minimum-cost objective.
"infeasible"No assignment of the requested cardinality exists.
"eps_optimal"An auction solve terminated at a caller-supplied
epsilon. The result is within n * epsilon of optimal, which is a bound,
not a certificate.
"iteration_limit"The solver stopped on an iteration cap rather than on optimality. The solution is feasible; its optimality is unproven.
"interrupted"The solver stopped on a time budget or a user interrupt, part way through. What it had placed respects every capacity, and is short of what the problem asked it to place, so it is neither an answer nor evidence that no answer exists.
"heuristic"Produced by a heuristic. Optimality is neither claimed nor checked.
Character vector of the permitted status values.
verify_assignment() to turn a status into a checked certificate.
solver_status_values()solver_status_values()
Divides units into K strata based on quantiles of the propensity score, then computes within-stratum weights for treatment effect estimation. This is a simple, transparent approach to propensity score adjustment that allows visual inspection of balance within each subclass.
subclass_match( formula = NULL, data = NULL, treatment = NULL, n_subclasses = 5L, ps = NULL, ps_model = NULL, estimand = "ATT" )subclass_match( formula = NULL, data = NULL, treatment = NULL, n_subclasses = 5L, ps = NULL, ps_model = NULL, estimand = "ATT" )
formula |
Formula for propensity score model (e.g.,
|
data |
Data frame containing all variables |
treatment |
Character, name of the binary treatment column (0/1) |
n_subclasses |
Integer, number of subclasses to create (default: 5). Cochran (1968) showed that 5 subclasses removes over 90\ a single covariate. |
ps |
Optional pre-computed numeric vector of propensity scores
(one per row in |
ps_model |
Optional pre-fitted |
estimand |
Target estimand: |
The algorithm:
Estimate propensity scores via logistic regression (or use pre-computed scores)
Divide the propensity score distribution into K quantile-based strata
For each stratum, check overlap (both treated and control units present)
Compute within-stratum weights based on the target estimand:
ATT: Treated units get weight 1; control units get
weight n_treated_in_stratum / n_control_in_stratum
ATE: Both groups get weight proportional to stratum size relative to total sample
ATC: Control units get weight 1; treated units get
weight n_control_in_stratum / n_treated_in_stratum
An S3 object of class c("subclass_result", "couplr_result")
containing:
Tibble with columns id, side, subclass,
ps, weight
Tibble with per-subclass statistics: counts, mean PS, and overlap status
List with n_subclasses, estimand, n_left,
n_right, method, vars
set.seed(42) n <- 200 data <- data.frame( id = 1:n, age = rnorm(n, 40, 10), income = rnorm(n, 50000, 15000) ) data$treatment <- rbinom(n, 1, plogis(-2 + 0.05 * data$age)) result <- subclass_match(treatment ~ age + income, data, treatment = "treatment") print(result)set.seed(42) n <- 200 data <- data.frame( id = 1:n, age = rnorm(n, 40, 10), income = rnorm(n, 50000, 15000) ) data$treatment <- rbinom(n, 1, plogis(-2 + 0.05 * data$age)) result <- subclass_match(treatment ~ age + income, data, treatment = "treatment") print(result)
Summary method for balance diagnostics
## S3 method for class 'balance_diagnostics' summary(object, ...)## S3 method for class 'balance_diagnostics' summary(object, ...)
object |
A balance_diagnostics object |
... |
Additional arguments (ignored) |
A list containing summary statistics (invisibly)
Summary Method for Distance Objects
## S3 method for class 'distance_object' summary(object, ...)## S3 method for class 'distance_object' summary(object, ...)
object |
A distance_object |
... |
Additional arguments (ignored) |
Invisibly returns the input object.
Extract summary information from k-best assignment results.
## S3 method for class 'lap_solve_kbest_result' summary(object, ...)## S3 method for class 'lap_solve_kbest_result' summary(object, ...)
object |
An object of class |
... |
Additional arguments (unused). |
A tibble with one row per solution containing:
rank: solution rank
solution_id: solution identifier
total_cost: total cost of the solution
n_assignments: number of assignments in the solution
Summary method for matching results
## S3 method for class 'matching_result' summary(object, ...)## S3 method for class 'matching_result' summary(object, ...)
object |
A matching_result object |
... |
Additional arguments (ignored) |
A list containing summary statistics (invisibly)
Summary method for sensitivity analysis
## S3 method for class 'sensitivity_analysis' summary(object, ...)## S3 method for class 'sensitivity_analysis' summary(object, ...)
object |
A sensitivity_analysis object |
... |
Additional arguments (ignored) |
A list containing summary statistics (invisibly)
Apply new constraints to a precomputed distance object without recomputing the underlying distances. This is useful for exploring different constraint scenarios quickly.
update_constraints(dist_obj, max_distance = Inf, calipers = NULL)update_constraints(dist_obj, max_distance = Inf, calipers = NULL)
dist_obj |
A distance_object from |
max_distance |
Maximum allowed distance (pairs with distance > max_distance become Inf) |
calipers |
Named list of per-variable calipers |
This function creates a new distance_object with modified constraints applied to the cost matrix. The original distance_object is not modified.
Constraints:
max_distance: Sets cost to Inf for pairs exceeding this threshold
calipers: Per-variable restrictions (e.g., calipers = list(age = 5))
The function returns a new object rather than modifying in place, following R's copy-on-modify semantics.
A new distance_object with updated cost_matrix
left <- data.frame(id = 1:5, age = c(25, 30, 35, 40, 45)) right <- data.frame(id = 6:10, age = c(24, 29, 36, 41, 44)) dist_obj <- compute_distances(left, right, vars = "age") # Apply constraints constrained <- update_constraints(dist_obj, max_distance = 2) result <- match_couples(constrained)left <- data.frame(id = 1:5, age = c(25, 30, 35, 40, 45)) right <- data.frame(id = 6:10, age = c(24, 29, 36, 41, 44)) dist_obj <- compute_distances(left, right, vars = "age") # Apply constraints constrained <- update_constraints(dist_obj, max_distance = 2) result <- match_couples(constrained)
Checks a solved assignment against the linear-programming optimality
conditions and returns the result of each check. Unlike the status field on
a solve result, which records what the solver terminated on, this is a
statement about the matching: certified_optimal is TRUE only when every
condition holds.
verify_assignment( x, cost = NULL, duals = NULL, maximize = FALSE, tol = 1e-09, arithmetic = c("auto", "exact", "double") ) ## S3 method for class 'assignment_certificate' print(x, ...)verify_assignment( x, cost = NULL, duals = NULL, maximize = FALSE, tol = 1e-09, arithmetic = c("auto", "exact", "double") ) ## S3 method for class 'assignment_certificate' print(x, ...)
x |
An |
cost |
Numeric cost matrix the assignment was computed on, or a lazy
cost specification from |
duals |
Optional list with elements |
maximize |
Logical; whether the assignment maximized rather than
minimized. Defaults to |
tol |
Numeric tolerance for the feasibility and slackness comparisons of a numerical certificate, ignored by an exact one. The duality-gap comparison scales it by the magnitude of the objective, since an absolute tolerance on a sum of many terms is not reachable in double precision. |
arithmetic |
One of |
... |
Ignored. |
An object of class assignment_certificate, a list with elements:
certified_optimal — logical, the conclusion. TRUE only when
every condition below holds.
arithmetic — "exact" or "double", the arithmetic the
conclusion was reached in.
exact_certificate — logical; whether the conditions hold in exact
arithmetic. arithmetic = "double" does not ask the question, and
reports FALSE here and in exact_available.
exact_available — logical; whether the exact question was asked of
every condition. It is FALSE under arithmetic = "double", and on
a certificate assembled from a scan that priced part of the problem
against a tolerance rather than evaluating it, which is what the
edge-generation loop hands back.
n_exact_violations, n_exact_untight — pairs failing exact dual
feasibility, and matched pairs not exactly tight, under the duals
given. Potentials recovered from the matching prove the matching and
leave these counts describing the duals that missed.
exact_duals_source — which potentials decided the exact
certificate: "solver" for the duals as supplied or solved,
"supplied" for expansions passed in duals, "recovered" for
potentials recovered from the matching, and "none" when no exact
certificate was reached.
exact_u, exact_v — on an exact certificate, the row and
column potentials that decided it, one row per potential whose sum
is its exact value (a single column when the solver's doubles
decided it); NULL when there is no exact certificate.
structurally_valid_matching — logical; no column claimed twice, no
forbidden pair matched, no index out of range. Unmatched rows are
permitted, so this holds for a partial matching.
all_rows_matched — logical; every row of the short side holds a
column.
primal_feasible — logical; both of the two above. The primal
constrains every row of the short side to hold exactly one pair, so
a partial matching is a valid matching and not a feasible solution,
and no conclusion rests on it. primal_objective is still reported
for one, since an unmatched row costs nothing and leaves the sum
meaningful.
dual_feasible — logical; c_ij - u_i - v_j >= -tol over every
admissible pair, and, when there are more columns than rows,
v_j <= tol for every column.
complementary_slackness — logical; both halves.
cs_matched_tight, cs_unmatched_free — the two halves separately.
primal_objective, dual_objective, duality_gap — numeric.
max_suboptimality — numeric; the most any feasible solution can
beat this one by, in the cost unit. It adds to the duality gap the
slack the dual conditions were allowed: n_rows times the depth the
reduced costs were permitted below zero, plus, where the sign
condition applies, n_cols times the height the column duals were
permitted above it, plus an envelope for each objective's own
rounding. Compensated summation buys back the accumulation error
rather than removing it, so each sum is charged
(2u + gamma_n^2) times the sum of its terms' magnitudes and the
assembly is rounded outward at every step. The number is an upper
bound in double arithmetic and not an estimate of one. An exact
certificate proves the matching optimal and reports zero. NA when
primal_feasible is
FALSE: the quantity is what a feasible solution can beat this
one by, and there is no answer for a candidate that is not one.
certified_reduced_cost_floor — numeric; the lower bound proved for
the reduced cost of every admissible pair, the ones never evaluated
included. Equal to min_reduced_cost when every pair was visited,
and below it when a pruning pricer proved only its own threshold.
min_reduced_cost, worst_i, worst_j — the most violated pair, if
any.
max_matched_slack, max_v_unmatched, max_v — the quantities the
slackness and sign conditions bound.
n_matched, n_rows, n_cols, transposed, tolerance.
Invisibly returns x.
The conditions are decided in one of two arithmetics, and the certificate says which one it used.
An exact certificate decides every condition in exact arithmetic, with no
tolerance anywhere. It proves that the matching attains the minimum total
cost of the cost matrix as supplied. The three quantities in each condition
are IEEE doubles, and a double is a rational number, so c_ij - u_i - v_j
has an exact sign; the check evaluates that sign exactly rather than reading
the sign of a rounded difference. A cost matrix that is itself a rounding of
something else — Mahalanobis distances, say — is still certified as the
matrix it is, which is the problem the solver was given.
A numerical certificate decides the same conditions within tol. It
establishes optimality up to that tolerance and no further. It is what is
available when the potentials are not exactly optimal for the matrix: a
potential computed as c_ij - v_j misses exact tightness by the rounding of
that subtraction, and a matched arc off by one unit in the last place is
enough to put the exact conclusion out of reach.
The exact certificate does not depend on the solver's potentials being exact. Optimal duals are sums and differences of cost entries, and a double often cannot hold one: on computed costs such as Euclidean or Mahalanobis distances the solver's potentials miss exact tightness on some matched arc by a unit in the last place. When the duals given pass the numerical reading but not the exact one, exact potentials are recovered from the matching itself, as shortest-path distances over the cost entries held in exact multi-component arithmetic (Shewchuk 1997), and the conditions are decided on those. A matching that is not optimal has no such potentials, so the recovery cannot certify one; it finds the improving cycle or path instead, and the numerical reading stands. Duals that fail even the numerical reading certify nothing, and the check fails on them. The recovery costs one pass over the admissible pairs.
Exact potentials generally do not fit in one double each, so they are
returned as exact_u and exact_v, matrices with one row per potential
whose row sums are the exact values. Summing each row in rational
arithmetic (for example with gmp::as.bigq) reproduces them, and passing
them back as duals has the conditions checked again from the cost matrix
alone, so the certificate can be re-checked without trusting this code.
The check needs dual variables. If x carries them (as
assignment_duals() results do), they are used. Otherwise they are obtained
by solving cost with assignment_duals(), which costs a second solve.
Either way the duals are verified, not trusted: dual feasibility is checked
over every admissible pair, so duals that do not certify anything cause the
verification to fail rather than pass.
Optimal duals are shared by all optimal solutions of a linear program, so a matching from one solver can be certified against duals from another. That is what makes it possible to certify solvers that return no duals of their own.
assignment(), assignment_duals(), solver_status_values()
set.seed(1) cost <- matrix(runif(100), 10, 10) verify_assignment(assignment(cost), cost) # A rectangular problem, where the condition on unmatched columns bites. # Passing the duals result reuses its duals instead of solving again. rect <- matrix(runif(120), 6, 20) verify_assignment(assignment_duals(rect), rect) # Integer costs carry an integer optimal dual solution, so the conditions # hold with no tolerance and the certificate is exact. int_cost <- matrix(sample(1:100, 64, replace = TRUE), 8, 8) cert <- verify_assignment(assignment(int_cost), int_cost) cert$arithmeticset.seed(1) cost <- matrix(runif(100), 10, 10) verify_assignment(assignment(cost), cost) # A rectangular problem, where the condition on unmatched columns bites. # Passing the duals result reuses its duals instead of solving again. rect <- matrix(runif(120), 6, 20) verify_assignment(assignment_duals(rect), rect) # Integer costs carry an integer optimal dual solution, so the conditions # hold with no tolerance and the certificate is exact. int_cost <- matrix(sample(1:100, 64, replace = TRUE), 8, 8) cert <- verify_assignment(assignment(int_cost), int_cost) cert$arithmetic
Checks a flow and a set of node potentials against the linear-programming
optimality conditions for the minimum-cost flow problem, and returns the
result of each check. Unlike the status field on a solve result, which
records what the solver terminated on, this is a statement about the flow:
certified_optimal is TRUE only when every condition holds.
verify_flow( x, problem = NULL, potential = NULL, tol = 1e-09, arithmetic = c("auto", "exact", "double") ) ## S3 method for class 'flow_certificate' print(x, ...)verify_flow( x, problem = NULL, potential = NULL, tol = 1e-09, arithmetic = c("auto", "exact", "double") ) ## S3 method for class 'flow_certificate' print(x, ...)
x |
A |
problem |
The flow problem the flow belongs to: a |
potential |
Optional numeric vector of node potentials, one per node.
Overrides any potentials on |
tol |
Relative tolerance for the feasibility and slackness comparisons. Each arc scales it by the largest of its cost and its two potentials, and the duality-gap comparison scales it by the magnitude of the objective, since a threshold below the resolution of the arithmetic that produced a number is not one a correct answer can meet. Ignored by an exact certificate. |
arithmetic |
One of |
... |
Ignored. |
The conditions are decided in one of two arithmetics, as they are by
verify_assignment(), and the certificate says which. The exact
reading decides the sign of every residual arc's reduced cost with no
tolerance: the flow is integral, so conservation and the bounds are exact
already, and the duality gap, being the sum of the slackness violations
weighted by |cbar|, is then exactly zero. When the potentials supplied
pass the numerical reading but miss exactness, which on computed costs they
usually do by a unit in the last place, exact potentials are recovered from
the flow itself as the shortest-path distances of its residual graph, held
in exact multi-component arithmetic. A flow that is not optimal has none,
because its residual graph carries a negative cycle, so the recovery cannot
certify one, and potentials that fail even the numerical reading certify
nothing. The numerical reading makes the comparisons within a relative
tolerance and establishes optimality to that tolerance.
The problem is
min sum_a cost(a) f(a)
s.t. (flow out of v) - (flow into v) = supply(v) at every node v
lower(a) <= f(a) <= upper(a) on every arc a
and, writing cbar(a) = cost(a) + pi(tail(a)) - pi(head(a)) for the reduced
cost under the potentials pi, the conditions checked are primal
feasibility, cbar(a) >= -tol(a) on every arc that can still take flow, and
cbar(a) <= tol(a) on every arc carrying more than its lower bound. Their
objective form is checked too: the duality gap is the sum of the slackness
violations weighted by |cbar|, so it is where a violation too small to
trip the per-arc tolerance still accumulates.
tol is relative. Each arc is compared against
tol(a) = tol * max(1, |cost(a)|, |pi(tail(a))|, |pi(head(a))|)
because cbar(a) is computed from those three numbers, and its last bits are
worth the largest of them times the machine epsilon. A design that stacks
lexicographic tier weights, as cardinality_match() does to rank cardinality
above balance above distance, reaches potentials in the millions, where one
unit in the last place is around 1e-9 and an exactly optimal flow cannot meet
an absolute 1e-9. The scale never falls below 1, so a problem whose costs and
potentials are of order 1 is checked against tol itself. The widest
tolerance any comparison used is reported as dual_tolerance.
The check needs potentials. If x carries them, as a solve result does,
they are used. Otherwise they are obtained by solving problem, which costs
a second solve. Either way they are verified, not trusted: an arc priced
below the tolerance makes the verification fail rather than pass.
Optimal potentials are shared by all optimal solutions of a linear program, so a flow from one solver can be certified against potentials from another.
An object of class flow_certificate, a list with elements:
certified_optimal - logical, the conclusion. TRUE only when
every condition below holds.
primal_feasible - logical; every arc inside its bounds and every
node's net flow equal to its supply.
n_capacity_violations, n_conservation_violations,
max_conservation_error - what primal feasibility failed on.
dual_feasible - logical; no arc that can still take flow prices
below its own -tol(a).
complementary_slackness - logical; no arc above its lower bound
prices above its own tol(a).
n_cs_violations, min_residual_reduced_cost, worst_arc - the
smallest reduced cost over the residual graph and the arc attaining
it, which is a violation when it falls below that arc's -tol(a).
worst_arc is 0 when no arc can either take or give up flow.
dual_tolerance - the widest tol(a) any comparison was made
against, so the verdict names the resolution it was reached at.
primal_objective, dual_objective, duality_gap - numeric.
arithmetic - "exact" or "double", the arithmetic the
conclusion was reached in.
exact_available, exact_certificate - whether the exact question
was asked, and whether its conditions hold.
n_exact_violations - residual arcs whose reduced cost has the
wrong sign exactly, under the potentials given.
exact_potentials_source - which potentials decided the exact
certificate: "solver", "supplied", "recovered", or "none".
exact_potential - on an exact certificate, the node potentials
that decided it, one row per node whose sum is its exact value;
NULL when there is no exact certificate.
tolerance - the relative tol as supplied.
Invisibly returns x.
verify_assignment(), solver_status_values()
# Two supply nodes shipping to two demand nodes, stated directly as a flow. prob <- list( n_nodes = 4, supply = c(2, 1, -2, -1), arcs = data.frame( tail = c(1, 1, 2, 2), head = c(3, 4, 3, 4), lower = c(0, 0, 0, 0), upper = c(2, 2, 2, 2), cost = c(1, 3, 2, 1) ) ) # Both of node 1's units go to node 3, node 2's unit goes to node 4. verify_flow(c(2, 0, 0, 1), prob, potential = c(0, 0, 1, 1)) # The same flow against potentials that certify nothing. verify_flow(c(2, 0, 0, 1), prob, potential = c(0, 0, 0, 0))# Two supply nodes shipping to two demand nodes, stated directly as a flow. prob <- list( n_nodes = 4, supply = c(2, 1, -2, -1), arcs = data.frame( tail = c(1, 1, 2, 2), head = c(3, 4, 3, 4), lower = c(0, 0, 0, 0), upper = c(2, 2, 2, 2), cost = c(1, 3, 2, 1) ) ) # Both of node 1's units go to node 3, node 2's unit goes to node 4. verify_flow(c(2, 0, 0, 1), prob, potential = c(0, 0, 1, 1)) # The same flow against potentials that certify nothing. verify_flow(c(2, 0, 0, 1), prob, potential = c(0, 0, 0, 0))