Skip to content

Matching

match_optimal

match_optimal(df: DataFrame, treatment_col: str = TREATMENT_COL, ps_col: str = PS_COL, pid_col: str = PID_COL, n_controls: int = 1, caliper: float = None) -> pd.DataFrame

Matches treated individuals to control individuals based on propensity scores with the option to specify the number of controls per treated individual and a caliper.

This function uses optimal matching to minimize the total distance between treated and control subjects, which typically produces better overall balance than greedy matching approaches.

Parameters:

Name Type Description Default
df DataFrame

DataFrame containing treated and control individuals.

required
treatment_col str

Column name indicating treatment status.

TREATMENT_COL
ps_col str

Column name for propensity score.

PS_COL
pid_col str

Column name for individual ID.

PID_COL
n_controls int

Number of controls to match for each treated individual. Must be >= 1. Common values: - 1: 1:1 matching (most common, maximizes precision) - 2-5: small ratios for bias-variance tradeoff - 10+: large ratios when controls are abundant

1
caliper float

Maximum allowable distance (propensity score difference) for matching. Must be >= 0 when provided. If None, no caliper is applied. Common values: - 0.1: loose caliper, allows moderate PS differences - 0.05: moderate caliper, good balance of matches vs quality - 0.01-0.02: tight caliper, ensures close PS matches - 0.25*std(PS): standard recommendation (Rosenbaum & Rubin, 1985)

None

Returns:

Type Description
DataFrame

pd.DataFrame: DataFrame with treated_pid, control_pid and distance columns.

Raises:

Type Description
ValueError

If n_controls < 1 or caliper < 0.

Source code in CausalEstimate/matching/matching.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def match_optimal(
    df: pd.DataFrame,
    treatment_col: str = TREATMENT_COL,
    ps_col: str = PS_COL,
    pid_col: str = PID_COL,
    n_controls: int = 1,
    caliper: float = None,
) -> pd.DataFrame:
    """
    Matches treated individuals to control individuals based on propensity scores
    with the option to specify the number of controls per treated individual and a caliper.

    This function uses optimal matching to minimize the total distance between treated
    and control subjects, which typically produces better overall balance than greedy
    matching approaches.

    Args:
        df (pd.DataFrame): DataFrame containing treated and control individuals.
        treatment_col (str): Column name indicating treatment status.
        ps_col (str): Column name for propensity score.
        pid_col (str): Column name for individual ID.
        n_controls (int): Number of controls to match for each treated individual.
                         Must be >= 1. Common values:
                         - 1: 1:1 matching (most common, maximizes precision)
                         - 2-5: small ratios for bias-variance tradeoff
                         - 10+: large ratios when controls are abundant
        caliper (float): Maximum allowable distance (propensity score difference) for matching.
                        Must be >= 0 when provided. If None, no caliper is applied.
                        Common values:
                        - 0.1: loose caliper, allows moderate PS differences
                        - 0.05: moderate caliper, good balance of matches vs quality
                        - 0.01-0.02: tight caliper, ensures close PS matches
                        - 0.25*std(PS): standard recommendation (Rosenbaum & Rubin, 1985)

    Returns:
        pd.DataFrame: DataFrame with treated_pid, control_pid and distance columns.

    Raises:
        ValueError: If n_controls < 1 or caliper < 0.
    """
    check_required_columns(df, [treatment_col, ps_col, pid_col])
    check_unique_pid(df, pid_col)
    check_ps_validity(df, ps_col)

    # Parameter validation
    if n_controls < 1:
        raise ValueError("n_controls must be >= 1")
    if caliper is not None and caliper < 0:
        raise ValueError("caliper must be >= 0 when provided")

    treated_df = filter_by_column(df, treatment_col, 1)
    control_df = filter_by_column(df, treatment_col, 0)

    distance_matrix = compute_distance_matrix(treated_df, control_df, ps_col)

    if caliper is not None:
        distance_matrix[distance_matrix > caliper] = (
            0  # this will ignore all distances greater than the caliper
        )

    distance_matrix, treated_df = filter_treated_w_insufficient_controls(
        distance_matrix, treated_df, n_controls
    )
    validate_control_availability(treated_df, control_df, n_controls)
    # print(dist_mat)
    distance_matrix = np.repeat(
        distance_matrix, repeats=n_controls, axis=0
    )  # repeat the matrix n_controls times
    row_ind, col_ind = assign_controls(distance_matrix)

    matched_distances = distance_matrix[row_ind, col_ind].reshape(
        -1, n_controls
    )  # n_cases x n_controls
    col_ind = col_ind.reshape(-1, n_controls)  # n_cases x n_controls

    result = create_matched_df(
        matched_distances, treated_df, control_df, pid_col, n_controls, col_ind
    )
    return result

match_eager

match_eager(df: DataFrame, treatment_col: str = TREATMENT_COL, ps_col: str = PS_COL, pid_col: str = PID_COL, caliper: float = None, n_controls: int = 1, strict: bool = True) -> pd.DataFrame

Performs a greedy nearest-neighbor matching based on propensity scores, allowing multiple controls per treated subject. By default, n_controls=1.

This function uses a greedy approach that matches each treated subject to their nearest available control(s) in order. While faster than optimal matching, it may not achieve the best overall balance across all matches.

Matching proceeds in multiple "passes": - Pass 1: each treated tries to find its first best control - Pass 2: each treated tries to find its second best control, etc.

Parameters:

Name Type Description Default
df DataFrame

Input dataframe.

required
treatment_col str

Name of treatment column (1=treated, 0=control).

TREATMENT_COL
ps_col str

Name of propensity score column.

PS_COL
pid_col str

Name of patient ID column.

PID_COL
caliper float

Maximum allowed absolute difference in PS for matching. Must be >= 0 when provided. If no control is within the caliper, that treated subject remains unmatched (or raises ValueError if strict=True). Common values: - 0.1: loose caliper, allows moderate PS differences - 0.05: moderate caliper, good balance of matches vs quality - 0.01-0.02: tight caliper, ensures close PS matches

None
n_controls int

How many distinct control matches to find per treated subject. Must be >= 1. Common values: - 1: 1:1 matching (most common, maximizes precision) - 2-5: small ratios for bias-variance tradeoff - 10+: large ratios when controls are abundant

1
strict bool

If True, raise a ValueError if any treated subject fails to find a control at any pass. If False, skip unmatched passes.

True

Returns:

Type Description
DataFrame

pd.DataFrame with columns [treated_pid, control_pid, distance].

DataFrame

This may contain up to (n_controls * number_of_treated) rows,

DataFrame

if all can be matched on every pass.

Raises:

Type Description
ValueError

If strict=True and a treated subject cannot be matched on any pass, or if n_controls < 1 or caliper < 0.

Source code in CausalEstimate/matching/matching.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
def match_eager(
    df: pd.DataFrame,
    treatment_col: str = TREATMENT_COL,
    ps_col: str = PS_COL,
    pid_col: str = PID_COL,
    caliper: float = None,
    n_controls: int = 1,
    strict: bool = True,
) -> pd.DataFrame:
    """
    Performs a greedy nearest-neighbor matching based on propensity scores,
    allowing multiple controls per treated subject. By default, n_controls=1.

    This function uses a greedy approach that matches each treated subject to their
    nearest available control(s) in order. While faster than optimal matching,
    it may not achieve the best overall balance across all matches.

    Matching proceeds in multiple "passes":
    - Pass 1: each treated tries to find its first best control
    - Pass 2: each treated tries to find its second best control, etc.

    Args:
        df (pd.DataFrame): Input dataframe.
        treatment_col (str): Name of treatment column (1=treated, 0=control).
        ps_col (str): Name of propensity score column.
        pid_col (str): Name of patient ID column.
        caliper (float, optional): Maximum allowed absolute difference in PS for matching.
                                   Must be >= 0 when provided. If no control is within the caliper,
                                   that treated subject remains unmatched (or raises ValueError if strict=True).
                                   Common values:
                                   - 0.1: loose caliper, allows moderate PS differences
                                   - 0.05: moderate caliper, good balance of matches vs quality
                                   - 0.01-0.02: tight caliper, ensures close PS matches
        n_controls (int): How many distinct control matches to find per treated subject.
                         Must be >= 1. Common values:
                         - 1: 1:1 matching (most common, maximizes precision)
                         - 2-5: small ratios for bias-variance tradeoff
                         - 10+: large ratios when controls are abundant
        strict (bool): If True, raise a ValueError if any treated subject fails to find
                       a control at any pass. If False, skip unmatched passes.

    Returns:
        pd.DataFrame with columns [treated_pid, control_pid, distance].
        This may contain up to (n_controls * number_of_treated) rows,
        if all can be matched on every pass.

    Raises:
        ValueError: If strict=True and a treated subject cannot be matched on any pass,
                   or if n_controls < 1 or caliper < 0.
    """
    # Parameter validation
    if n_controls < 1:
        raise ValueError("n_controls must be >= 1")
    if caliper is not None and caliper < 0:
        raise ValueError("caliper must be >= 0 when provided")

    # Separate treated vs. control
    treated_array = df.loc[df[treatment_col] == 1, [pid_col, ps_col]].values
    control_array = df.loc[df[treatment_col] == 0, [pid_col, ps_col]].values

    # Keep track of how many matches each treated has found
    # Map from treated_pid -> current match count
    # (only needed if we want to track partial matches in strict mode)
    match_count = {t_pid: 0 for t_pid, _ in treated_array}

    all_matches = []
    used_control = set()  # keep track of which controls are already matched

    # Repeat the greedy pass n_controls times
    for pass_index in range(n_controls):
        pass_matches = []  # store matches found in this pass

        for t_pid, t_ps in treated_array:
            # If this treated subject is already fully matched in previous passes, skip
            if match_count[t_pid] >= pass_index + 1:
                # already got pass_index matches
                continue

            # compute distance to all controls
            ps_diffs = np.abs(control_array[:, 1] - t_ps)

            # apply caliper if specified
            if caliper is not None:
                within_caliper = ps_diffs <= caliper
                if not within_caliper.any():
                    if strict:
                        raise ValueError(
                            f"Treated subject {t_pid} cannot find a control within caliper={caliper} "
                            f"on pass {pass_index+1} of {n_controls}."
                        )
                    else:
                        continue
                ps_diffs = ps_diffs[within_caliper]
                valid_control = control_array[within_caliper]
            else:
                valid_control = control_array

            if len(valid_control) == 0:
                if strict:
                    raise ValueError(
                        f"Treated subject {t_pid} cannot find any available control on pass {pass_index+1}. "
                        f"All possible controls are used or out of range."
                    )
                else:
                    continue

            # sort by smallest distance
            sorted_indices = np.argsort(ps_diffs)
            found_match = False

            for idx in sorted_indices:
                c_pid = valid_control[idx, 0]
                c_ps = valid_control[idx, 1]
                if c_pid not in used_control:
                    dist = abs(t_ps - c_ps)
                    pass_matches.append([t_pid, c_pid, dist])
                    used_control.add(c_pid)  # can't use it again
                    match_count[t_pid] += 1
                    found_match = True
                    break

            if not found_match and strict:
                raise ValueError(
                    f"Treated subject {t_pid} cannot find an available control on pass {pass_index+1}, "
                    "either because all valid controls are used up or out of range."
                )

        # merge pass_matches into our global list
        all_matches.extend(pass_matches)

    return pd.DataFrame(
        all_matches, columns=[TREATED_PID_COL, CONTROL_PID_COL, DISTANCE_COL]
    )