Skip to content

Diagnostics

compute_positivity_metrics

compute_positivity_metrics(df: DataFrame, ps_col: str = PS_COL, treatment_col: str = TREATMENT_COL, eps: float = 0.01, common_support_threshold: float = 0.05) -> dict

Compute positivity/overlap diagnostics for the propensity score.

Parameters:

Name Type Description Default
df DataFrame

Input DataFrame with treatment and propensity score columns.

required
ps_col str

Name of the propensity score column.

PS_COL
treatment_col str

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

TREATMENT_COL
eps float

Propensity scores outside [eps, 1 - eps] are counted as extreme.

0.01
common_support_threshold float

Quantile threshold passed to get_common_support_range.

0.05

Returns:

Type Description
dict

dict with sample sizes, shares of extreme propensity scores (overall and

dict

per arm), the trimmed common support range (intersection of the arms'

dict

quantile-trimmed PS ranges — observations can fall outside it even under

dict

perfect overlap when common_support_threshold > 0) and shares outside it,

dict

the KS test comparing the arms' propensity score distributions, and a

dict

flag_extreme_ps bool (True if any propensity scores fall outside

dict

[eps, 1 - eps] — evidence of a practical positivity problem, not proof of

dict

a formal violation).

Source code in CausalEstimate/diagnostics/positivity.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
def compute_positivity_metrics(
    df: pd.DataFrame,
    ps_col: str = PS_COL,
    treatment_col: str = TREATMENT_COL,
    eps: float = 0.01,
    common_support_threshold: float = 0.05,
) -> dict:
    """
    Compute positivity/overlap diagnostics for the propensity score.

    Args:
        df: Input DataFrame with treatment and propensity score columns.
        ps_col: Name of the propensity score column.
        treatment_col: Name of the treatment status column (1 treated, 0 control).
        eps: Propensity scores outside [eps, 1 - eps] are counted as extreme.
        common_support_threshold: Quantile threshold passed to
            get_common_support_range.

    Returns:
        dict with sample sizes, shares of extreme propensity scores (overall and
        per arm), the trimmed common support range (intersection of the arms'
        quantile-trimmed PS ranges — observations can fall outside it even under
        perfect overlap when common_support_threshold > 0) and shares outside it,
        the KS test comparing the arms' propensity score distributions, and a
        flag_extreme_ps bool (True if any propensity scores fall outside
        [eps, 1 - eps] — evidence of a practical positivity problem, not proof of
        a formal violation).
    """
    if not 0 < eps < 0.5:
        raise ValueError(f"eps must be in (0, 0.5), got {eps}.")
    n_treated, n_control = validate_ps_and_treatment(df, ps_col, treatment_col)

    treated_ps = get_treated_ps(df, treatment_col, ps_col)
    control_ps = get_untreated_ps(df, treatment_col, ps_col)
    ps = df[ps_col]
    support_low, support_high = get_common_support_range(
        df, treatment_col, ps_col, common_support_threshold
    )
    ks_stats = compute_propensity_score_stats(df, ps_col, treatment_col)

    def prop_extreme(s: pd.Series) -> float:
        return float(((s < eps) | (s > 1 - eps)).mean())

    def prop_outside(s: pd.Series) -> float:
        return float(((s < support_low) | (s > support_high)).mean())

    prop_ps_extreme = prop_extreme(ps)
    return {
        "n_total": int(len(df)),
        "n_treated": n_treated,
        "n_control": n_control,
        "prop_ps_below_eps": float((ps < eps).mean()),
        "prop_ps_above_1_minus_eps": float((ps > 1 - eps).mean()),
        "prop_ps_extreme": prop_ps_extreme,
        "prop_treated_ps_extreme": prop_extreme(treated_ps),
        "prop_control_ps_extreme": prop_extreme(control_ps),
        "common_support_low": float(support_low),
        "common_support_high": float(support_high),
        "prop_outside_support": prop_outside(ps),
        "prop_treated_outside_support": prop_outside(treated_ps),
        "prop_control_outside_support": prop_outside(control_ps),
        "ks_statistic": float(ks_stats["ks_statistic"]),
        "ks_p_value": float(ks_stats["p_value"]),
        "flag_extreme_ps": bool(prop_ps_extreme > 0),
    }

compute_weight_diagnostics

compute_weight_diagnostics(df: DataFrame, ps_col: str = PS_COL, treatment_col: str = TREATMENT_COL, weight_type: Literal['ATE', 'ATT'] = 'ATE', clip_percentile: float = 1) -> dict

Compute IPW weight diagnostics: effective sample size and weight summaries.

Weights are computed with compute_ipw_weights; clip_percentile=1 (default) means raw, unclipped weights, matching the estimators' default. Propensity scores of exactly 0 or 1 raise ValueError: their IPW weights are undefined and would only reflect the numerical stabilizer.

Parameters:

Name Type Description Default
df DataFrame

Input DataFrame with treatment and propensity score columns.

required
ps_col str

Name of the propensity score column.

PS_COL
treatment_col str

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

TREATMENT_COL
weight_type Literal['ATE', 'ATT']

"ATE" or "ATT".

'ATE'
clip_percentile float

Upper-tail clipping passed to compute_ipw_weights.

1

Returns:

Type Description
dict

dict with per-arm ESS, ESS as a fraction of arm size, and per-arm weight

dict

summaries (max, mean, 95th and 99th percentile). Pooled equivalents

dict

(ess_total, ess_fraction_total, max_weight, mean_weight, weight_q95,

dict

weight_q99) are included only for weight_type="ATE": ATT weights put the

dict

two arms on different scales (treated weights are identically 1), so

dict

pooled Kish ESS and pooled quantiles would be misleading there.

Source code in CausalEstimate/diagnostics/weights.py
 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
104
105
106
def compute_weight_diagnostics(
    df: pd.DataFrame,
    ps_col: str = PS_COL,
    treatment_col: str = TREATMENT_COL,
    weight_type: Literal["ATE", "ATT"] = "ATE",
    clip_percentile: float = 1,
) -> dict:
    """
    Compute IPW weight diagnostics: effective sample size and weight summaries.

    Weights are computed with compute_ipw_weights; clip_percentile=1 (default)
    means raw, unclipped weights, matching the estimators' default.
    Propensity scores of exactly 0 or 1 raise ValueError: their IPW weights are
    undefined and would only reflect the numerical stabilizer.

    Args:
        df: Input DataFrame with treatment and propensity score columns.
        ps_col: Name of the propensity score column.
        treatment_col: Name of the treatment status column (1 treated, 0 control).
        weight_type: "ATE" or "ATT".
        clip_percentile: Upper-tail clipping passed to compute_ipw_weights.

    Returns:
        dict with per-arm ESS, ESS as a fraction of arm size, and per-arm weight
        summaries (max, mean, 95th and 99th percentile). Pooled equivalents
        (ess_total, ess_fraction_total, max_weight, mean_weight, weight_q95,
        weight_q99) are included only for weight_type="ATE": ATT weights put the
        two arms on different scales (treated weights are identically 1), so
        pooled Kish ESS and pooled quantiles would be misleading there.
    """
    n_treated, n_control = validate_ps_and_treatment(df, ps_col, treatment_col)
    A = df[treatment_col].to_numpy()
    ps = df[ps_col].to_numpy()

    W = compute_ipw_weights(
        A, ps, weight_type=weight_type, clip_percentile=clip_percentile
    )
    W_treated = W[A == 1]
    W_control = W[A == 0]
    ess_treated = compute_ess(W_treated)
    ess_control = compute_ess(W_control)

    result = {
        "n_total": int(len(W)),
        "n_treated": n_treated,
        "n_control": n_control,
        "ess_treated": ess_treated,
        "ess_control": ess_control,
        "ess_fraction_treated": min(ess_treated / n_treated, 1.0),
        "ess_fraction_control": min(ess_control / n_control, 1.0),
        **_weight_summary(W_treated, "treated"),
        **_weight_summary(W_control, "control"),
    }
    if weight_type == "ATE":
        ess_total = compute_ess(W)
        result.update(
            {
                "ess_total": ess_total,
                "ess_fraction_total": min(ess_total / len(W), 1.0),
                "max_weight": float(W.max()),
                "mean_weight": float(W.mean()),
                "weight_q95": float(np.percentile(W, 95)),
                "weight_q99": float(np.percentile(W, 99)),
            }
        )
    return result

compute_ess

compute_ess(weights: ndarray) -> float

Effective sample size of a weighted sample (Kish): (sum w)^2 / sum(w^2).

Equals n for uniform weights and approaches 1 as a single weight dominates. Defined for nonnegative weights; nonfinite, negative, or all-zero weights raise ValueError.

Source code in CausalEstimate/diagnostics/weights.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def compute_ess(weights: np.ndarray) -> float:
    """
    Effective sample size of a weighted sample (Kish): (sum w)^2 / sum(w^2).

    Equals n for uniform weights and approaches 1 as a single weight dominates.
    Defined for nonnegative weights; nonfinite, negative, or all-zero weights
    raise ValueError.
    """
    w = np.asarray(weights, dtype=float)
    if w.size == 0:
        raise ValueError("weights must be non-empty.")
    if not np.all(np.isfinite(w)):
        raise ValueError("weights must be finite (no NaN or inf).")
    if np.any(w < 0):
        raise ValueError("weights must be nonnegative.")
    if w.max() == 0:
        raise ValueError("weights must not be all zero.")
    w = w / w.max()  # ESS is scale-invariant; normalizing prevents overflow
    return float(w.sum() ** 2 / (w**2).sum())

compute_ipw_weights

compute_ipw_weights(A: ndarray, ps: ndarray, weight_type: Literal['ATE', 'ATT'] = 'ATE', clip_percentile: float = 1, eps: float = 1e-09) -> np.ndarray

Computes Inverse Propensity Score (IPW) weights with optional clipping.

This function calculates weights for estimating the Average Treatment Effect (ATE) or the Average Treatment Effect on the Treated (ATT).

Formulas: - ATE: w = A/ps + (1-A)/(1-ps) - ATT: w = A + (1-A) * ps/(1-ps)

Args:
A: Binary treatment assignment vector (1 for treated, 0 for control).
ps: Propensity score vector (estimated probability of treatment).
weight_type: The type of estimand, either "ATE" or "ATT".
clip_percentile: The upper percentile at which to clip weights to prevent
                 extreme values. A value of 1.0 (default) applies no
                 clipping. For example, 0.99 clips the top 1%.
eps: A small constant to add to denominators for numerical stability.

Returns:

Type Description
ndarray

An array of computed IPW weights.

Raises:

Type Description
ValueError

If weight_type is invalid, shapes mismatch, or clip_percentile is out of bounds.

Source code in CausalEstimate/estimators/functional/utils.py
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
def compute_ipw_weights(
    A: np.ndarray,
    ps: np.ndarray,
    weight_type: Literal["ATE", "ATT"] = "ATE",
    clip_percentile: float = 1,
    eps: float = 1e-9,
) -> np.ndarray:
    """
    Computes Inverse Propensity Score (IPW) weights with optional clipping.

    This function calculates weights for estimating the Average Treatment Effect (ATE)
    or the Average Treatment Effect on the Treated (ATT).

    Formulas:
    - ATE: w = A/ps + (1-A)/(1-ps)
    - ATT: w = A + (1-A) * ps/(1-ps)

        Args:
        A: Binary treatment assignment vector (1 for treated, 0 for control).
        ps: Propensity score vector (estimated probability of treatment).
        weight_type: The type of estimand, either "ATE" or "ATT".
        clip_percentile: The upper percentile at which to clip weights to prevent
                         extreme values. A value of 1.0 (default) applies no
                         clipping. For example, 0.99 clips the top 1%.
        eps: A small constant to add to denominators for numerical stability.

    Returns:
        An array of computed IPW weights.

    Raises:
        ValueError: If `weight_type` is invalid, shapes mismatch, or
                    `clip_percentile` is out of bounds.
    """

    # --- 1. Input Validation ---
    if weight_type not in ["ATE", "ATT"]:
        raise ValueError("weight_type must be 'ATE' or 'ATT'")
    if not (0 < clip_percentile <= 1.0):
        raise ValueError("clip_percentile must be in the interval (0, 1.0].")
    if A.shape != ps.shape:
        raise ValueError("A and ps must have the same shape.")
    check_ps_not_exact_zero_one(ps)

    # --- 2. Core Weight Calculation ---
    if weight_type == "ATE":
        # Vectorized formula for ATE weights
        weights = A / (ps + eps) + (1 - A) / (1 - ps + eps)
    else:  # weight_type == "ATT"
        # For ATT, treated units have a weight of 1.
        # Vectorized formula for ATT weights.
        weights = A + (1 - A) * ps / (1 - ps + eps)

    # --- 3. Unified Weight Clipping ---
    if clip_percentile < 1.0:
        q = clip_percentile * 100

        # This logic is now applied to both ATE and ATT.
        # For ATT, the 'treated_mask' section is a no-op but is still executed.
        treated_mask = A == 1
        if np.any(treated_mask):
            threshold_t = np.percentile(weights[treated_mask], q)
            weights[treated_mask] = np.clip(
                weights[treated_mask], a_min=None, a_max=threshold_t
            )

        control_mask = ~treated_mask
        if np.any(control_mask):
            threshold_c = np.percentile(weights[control_mask], q)
            weights[control_mask] = np.clip(
                weights[control_mask], a_min=None, a_max=threshold_c
            )

    return weights

compute_evalue

compute_evalue(estimate: float, ci_lower: Optional[float] = None, ci_upper: Optional[float] = None, scale: Literal['RR', 'RD'] = 'RR', baseline_risk: Optional[float] = None) -> dict

E-value for unmeasured confounding (VanderWeele & Ding, 2017).

The E-value is the minimum strength of association, on the risk-ratio scale, that an unmeasured confounder would need with both treatment and outcome (conditional on measured covariates) to fully explain away the observed effect. For RR >= 1, E = RR + sqrt(RR * (RR - 1)); protective effects use 1 / RR. The CI E-value applies the same formula to the confidence bound closest to the null and is 1 when the interval contains the null.

Parameters:

Name Type Description Default
estimate float

Effect estimate on the given scale.

required
ci_lower Optional[float]

Lower confidence bound; pass together with ci_upper.

None
ci_upper Optional[float]

Upper confidence bound.

None
scale Literal['RR', 'RD']

"RR" for risk ratios (RR, RRT effect types), or "RD" for risk differences (ATE, ARR, ATT, ATC on a binary outcome). RD values are converted to risk ratios via (baseline_risk + RD) / baseline_risk.

'RR'
baseline_risk Optional[float]

Outcome risk in the untreated (e.g. the estimator's effect_0). Required when scale="RD".

None

Returns:

Type Description
dict

dict with keys "evalue" and "evalue_ci" (None if no CI was given).

Source code in CausalEstimate/diagnostics/sensitivity.py
22
23
24
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
def compute_evalue(
    estimate: float,
    ci_lower: Optional[float] = None,
    ci_upper: Optional[float] = None,
    scale: Literal["RR", "RD"] = "RR",
    baseline_risk: Optional[float] = None,
) -> dict:
    """
    E-value for unmeasured confounding (VanderWeele & Ding, 2017).

    The E-value is the minimum strength of association, on the risk-ratio scale,
    that an unmeasured confounder would need with both treatment and outcome
    (conditional on measured covariates) to fully explain away the observed
    effect. For RR >= 1, E = RR + sqrt(RR * (RR - 1)); protective effects use
    1 / RR. The CI E-value applies the same formula to the confidence bound
    closest to the null and is 1 when the interval contains the null.

    Args:
        estimate: Effect estimate on the given scale.
        ci_lower: Lower confidence bound; pass together with ci_upper.
        ci_upper: Upper confidence bound.
        scale: "RR" for risk ratios (RR, RRT effect types), or "RD" for risk
            differences (ATE, ARR, ATT, ATC on a binary outcome). RD values are
            converted to risk ratios via (baseline_risk + RD) / baseline_risk.
        baseline_risk: Outcome risk in the untreated (e.g. the estimator's
            `effect_0`). Required when scale="RD".

    Returns:
        dict with keys "evalue" and "evalue_ci" (None if no CI was given).
    """
    if scale not in {"RR", "RD"}:
        raise ValueError("scale must be 'RR' or 'RD'.")
    if (ci_lower is None) != (ci_upper is None):
        raise ValueError("ci_lower and ci_upper must be given together.")
    if scale == "RD":
        if baseline_risk is None:
            raise ValueError("baseline_risk is required when scale='RD'.")
        if not 0 < baseline_risk <= 1:
            raise ValueError("baseline_risk must be in (0, 1].")
    if ci_lower is not None and ci_lower > ci_upper:
        raise ValueError("ci_lower must not exceed ci_upper.")

    rr = _to_rr(estimate, scale, baseline_risk)
    result = {"evalue": _evalue_from_rr(rr), "evalue_ci": None}
    if ci_lower is None:
        return result

    lo = _to_rr(ci_lower, scale, baseline_risk)
    hi = _to_rr(ci_upper, scale, baseline_risk)
    if lo <= 1 <= hi:
        result["evalue_ci"] = 1.0
    else:
        result["evalue_ci"] = _evalue_from_rr(lo if rr > 1 else hi)
    return result