BalancingWeights(objective="cressie_read") extends the existing quadratic and entropy calibration paths with a Cressie-Read power-divergence link. The target problem is still ATT weighting: reweight controls so their balance-basis moments match the treated moments, then estimate the missing treated counterfactual from weighted controls.
Let \(b(X)\) be the balance basis. With controls indexed by \(D=0\), the exact normalized balance equations are
The \(\lambda=0\) case is the entropy/KL limit. The \(\lambda=1\) case is the Pearson chi-square / quadratic-distance member of the family, though the existing objective="quadratic" path keeps its original direct linear-weight implementation.
Rényi divergence is not a new solver in this branch. Here it is used as a diagnostic on the fitted weights:
It is tied to the Cressie-Read family by the same power moment, with \(\lambda=\alpha-1\). This gives a clean mapping between divergence values, but the implemented optimizer is still the Cressie-Read calibration map above, solved by Gauss-Newton/L-BFGS/BFGS.
Setup
Show code
from html import escapeimport matplotlib.pyplot as pltimport numpy as npimport pandas as pdimport crabbymetrics as cmdef html_table(df, float_format="{:.4f}"): rows = []for _, row in df.iterrows(): cells = []for value in row:ifisinstance(value, float): cells.append(float_format.format(value))else: cells.append(str(value)) rows.append(cells) out = ["<table>", "<thead>", "<tr>"] out.extend(f"<th>{escape(str(col))}</th>"for col in df.columns) out.extend(["</tr>", "</thead>", "<tbody>"])for row in rows: out.append("<tr>") out.extend(f"<td>{escape(cell)}</td>"for cell in row) out.append("</tr>") out.extend(["</tbody>", "</table>"])return"".join(out)def basis(x): x1 = x[:, 0] x2 = x[:, 1]return np.column_stack([x1, x2, x1**2, x2**2, x1 * x2])def y0_surface(x): x1 = x[:, 0] x2 = x[:, 1]return (1.0+0.8* x1-0.5* x2+0.35* x1**2-0.20* x2**2+0.45* x1 * x2 )def normalize_prob(weights): weights = np.asarray(weights, dtype=float) total = weights.sum()ifnot np.isfinite(total) or total <=0.0:raiseValueError("weights must have positive finite total mass")return weights / totaldef logsumexp(values): values = np.asarray(values, dtype=float) values = values[np.isfinite(values)]if values.size ==0:return-np.inf vmax = values.max()returnfloat(vmax + np.log(np.exp(values - vmax).sum()))def kl_divergence(p, q): p = normalize_prob(p) q = normalize_prob(q)if np.any((p >0.0) & (q <=0.0)):return np.inf mask = p >0.0returnfloat(np.sum(p[mask] * (np.log(p[mask]) - np.log(q[mask]))))def power_moment(p, q, alpha): p = normalize_prob(p) q = normalize_prob(q)if alpha <=0.0:raiseValueError("alpha must be positive for Renyi divergence")if alpha >1.0and np.any((p >0.0) & (q <=0.0)):return np.inf mask = (p >0.0) & (q >0.0) log_terms = alpha * np.log(p[mask]) + (1.0- alpha) * np.log(q[mask])returnfloat(np.exp(logsumexp(log_terms)))def renyi_divergence(p, q, alpha):if np.isclose(alpha, 1.0):return kl_divergence(p, q) moment = power_moment(p, q, alpha)ifnot np.isfinite(moment):return np.infif moment <=0.0:return np.infreturnfloat(np.log(moment) / (alpha -1.0))def cressie_read_statistic(p, q, lamb):# Common Cressie-Read normalization: lambda=1 is Pearson chi-square,# while lambda=0 is 2 * KL. The scale does not affect calibration. p = normalize_prob(p) q = normalize_prob(q)if np.isclose(lamb, 0.0):return2.0* kl_divergence(p, q)if np.isclose(lamb, -1.0):return2.0* kl_divergence(q, p) moment = power_moment(p, q, lamb +1.0)ifnot np.isfinite(moment):return np.infreturnfloat(2.0* (moment -1.0) / (lamb * (lamb +1.0)))def renyi_from_cressie_read_statistic(cr_value, lamb):if np.isclose(lamb, 0.0):return0.5* cr_value alpha = lamb +1.0 moment =1.0+0.5* lamb * alpha * cr_valuereturnfloat(np.log(moment) / lamb)def draw_data(shift, rng, n_control=600, n_treated=220, tau=1.0): x_control = rng.normal(size=(n_control, 2)) x_treated = rng.normal(loc=np.array([shift, -0.35* shift]), size=(n_treated, 2)) y_control = y0_surface(x_control) + rng.normal(scale=1.0, size=n_control) y_treated = y0_surface(x_treated) + tau + rng.normal(scale=1.0, size=n_treated)return x_control, x_treated, y_control, y_treated, tauMETHODS = [ ("Naive", None), ("Quadratic", {"objective": "quadratic", "solver": "auto"}), ("Entropy", {"objective": "entropy", "solver": "auto"}), ("CR lambda=0", {"objective": "cressie_read","solver": "lbfgs","divergence_power": 0.0,"dual_ridge": 1e-10, }, ), ("CR lambda=0.5", {"objective": "cressie_read","solver": "lbfgs","divergence_power": 0.5,"dual_ridge": 1e-10, }, ), ("CR lambda=1", {"objective": "cressie_read","solver": "lbfgs","divergence_power": 1.0,"dual_ridge": 1e-10, }, ),]def estimate_methods(x_control, x_treated, y_control, y_treated): b_control = basis(x_control) b_treated = basis(x_treated) rows = []for method, kwargs in METHODS:if kwargs isNone: rows.append( {"method": method,"estimate": float(y_treated.mean() - y_control.mean()),"ess": float(len(y_control)),"max_abs_balance": float( np.max(np.abs(b_control.mean(axis=0) - b_treated.mean(axis=0))) ),"success": True,"solver": "--", } )continue model = cm.BalancingWeights(max_iterations=500, tolerance=1e-7, **kwargs) model.fit(b_control, b_treated) summary = model.summary() weights = np.asarray(summary["weights"]) rows.append( {"method": method,"estimate": float(y_treated.mean() - np.dot(weights, y_control)),"ess": float(summary["effective_sample_size"]),"max_abs_balance": float(summary["max_abs_diff"]),"success": bool(summary["success"]),"solver": str(summary["solver"]), } )return rows
The naive estimator gets worse as treated and control covariate distributions move apart. The balancing estimators drive the chosen basis moments toward the treated moments, but the effective sample size falls as overlap deteriorates.
So there is a clean mapping in the divergence values: \(D_\alpha\) and \(CR_{\alpha-1}\) are monotone transformations of the same scalar \(M_\alpha\). The table below fits the mapped Cressie-Read calibration member, then reports both the Rényi value and the mapped Cressie-Read statistic for the fitted weights.
The mapping gap is numerical zero because the two columns are different transformations of the same fitted weights and the same power moment. That is the sense in which Rényi order \(\alpha\) maps to Cressie-Read index \(\lambda=\alpha-1\).
That mapping is not the same thing as saying the current implementation has a separate Rényi optimizer. The current implementation fits weights through the Cressie-Read dual map and a nonlinear least-squares calibration criterion. Rényi is reported here as a divergence profile for those fitted weights. A paper-faithful Rényi objective would optimize the Rényi criterion directly, and the Bregman proximal algorithm would change the update geometry rather than simply switching from BFGS to L-BFGS.
Reading The Results
Good overlap is forgiving. All calibrated estimators have high effective sample size and nearly identical balance diagnostics, so the choice of distance geometry barely matters.
Medium overlap is where the distance family starts to show up. The estimators still solve essentially the same moment problem, but they distribute the adjustment differently across controls. Entropy and the \(\lambda=0\) Cressie-Read limit should move together. Larger \(\lambda\) values behave more like Pearson/quadratic distance. Lower Rényi orders correspond to negative Cressie-Read indices; they flatten the divergence’s sensitivity to large ratios, but the dual map has a tighter natural domain and can fail before exact balance is reached.
Bad overlap is the design warning. Exact moment balance may still be numerically feasible, but it is bought with lower effective sample size and more concentrated weights. The Cressie-Read parameter changes the path to balance; it does not identify missing support. The useful diagnostics are the same ones already returned by BalancingWeights: success, maximum balance error, weight bounds, and effective sample size.
The support lesson is the same one as in the Rényi formula. If a treated-region distribution puts mass where the control baseline has zero mass, KL and Rényi with \(\alpha\ge 1\) blow up. For \(0<\alpha<1\), the zero-baseline term does not blow up in the divergence value, but that does not create usable controls in that region. It is a robustness knob for the distance, not an identification argument.