Raking weights with Python and weightpipe
In my 2012 post on raking weights in R, I used anesrake to adjust a Chilean public opinion survey to known population margins. In this post, I revisit the same example in Python using weightpipe, a package I wrote for survey weighting recipes.
The basic ideas—when to use raking, how to select variables, why extreme weights may need truncation, and how to examine the design effect—have not changed. The original post discusses that background. Here I concentrate on the Python workflow and extend the example to estimation and nonresponse adjustment.
Install the published package from PyPI (Python 3.11+):
pip install weightpipe
# or
uv add weightpipe
Data
As in the original post, I use the CEP Public Opinion Survey from July–August 2012 to estimate presidential approval (data). Five variables enter the raking: sex, agecat, ses, region, and area.
import numpy as np
import pandas as pd
pd.set_option("display.notebook_repr_html", False)
pd.set_option("display.float_format", lambda x: f"{x:.3f}")
pd.set_option("display.width", 120)
pd.set_option("display.max_columns", None)
from weightpipe import (
WeightPipe,
design_effect,
weight_factors,
)
dat = pd.read_csv(
"../_R/data/cep.csv"
)
for code, name in [(1, "approve"), (2, "disapprove"), (3, "unsure"), (9, "dk")]:
dat[name] = (dat["approval"] == code).astype(int)
dat[["sex", "agecat", "ses", "region", "area", "pond", "approval"]].head() sex agecat ses region area pond approval
0 1 2 2 13 1 1.977 2
1 1 5 2 13 1 1.243 1
2 2 2 3 9 2 0.514 2
3 1 5 3 9 1 0.421 1
4 1 5 4 10 1 0.526 1
Sample margins (unweighted) for the raking variables:
for var in ["sex", "agecat", "ses", "region", "area"]:
print(f"\n{var}")
print(
dat[var]
.value_counts(normalize=True)
.sort_index()
.rename_axis(None)
.round(3)
.to_string()
)sex
1 0.407
2 0.593
agecat
1 0.124
2 0.159
3 0.177
4 0.194
5 0.346
ses
1 0.039
2 0.108
3 0.365
4 0.448
5 0.040
region
1 0.013
2 0.042
3 0.014
4 0.042
5 0.099
6 0.055
7 0.062
8 0.131
9 0.063
10 0.049
11 0.004
12 0.011
13 0.376
14 0.026
15 0.013
area
1 0.837
2 0.163
Population targets
Population shares come from the Chilean Census 2002 (sex, agecat, region, area) and the Bicentenario Survey 2009 (ses) — same targets as in 2012. Rounded census vectors may not sum exactly to one; weightpipe renormalizes them by default (force1=True), as in anesrake.
# Chilean Census 2002
sex = {1: 0.49, 2: 0.51} # 1 male, 2 female
agecat = {
1: 0.163, # 18-24
2: 0.203, # 25-34
3: 0.195, # 35-44
4: 0.187, # 45-54
5: 0.253, # 55+
}
region = {
1: 0.015, 2: 0.031, 3: 0.016, 4: 0.039, 5: 0.102,
6: 0.051, 7: 0.059, 8: 0.123, 9: 0.056, 10: 0.046,
11: 0.006, 12: 0.010, 13: 0.408, 14: 0.023, 15: 0.013,
}
area = {1: 0.869, 2: 0.131} # 1 urban, 2 rural
# Bicentenario Survey 2009
ses = {1: 0.109, 2: 0.184, 3: 0.261, 4: 0.364, 5: 0.083} # abc1..e
proportions = {
"sex": sex,
"agecat": agecat,
"ses": ses,
"region": region,
"area": area,
}Raking with weightpipe
WeightPipe(dat) assigns unit base weights automatically when no design weights are supplied. I then define the two adjustment steps:
-
calibrate(method="raking", proportions=...)to run iterative proportional fitting against the population margins. -
trim(max_ratio=5, reference="value")to cap weights at five and redistribute the excess so that the total weight is preserved.
Unlike anesrake, this step does not select variables using options such as pctlim and nlim. I pass the margins selected for the analysis directly. Weights are computed on first use of collect_weights(), weights, or estimate().
pipe = (
WeightPipe(dat)
.options(warn=False)
.calibrate(
method="raking",
proportions=proportions,
max_iter=100,
tol=1e-8,
)
.trim(max_ratio=5.0, reference="value", redistribute=True)
)
weighted = pipe.collect_weights(keep_intermediate=True)
print("n =", len(weighted))
print("sum(weight) =", round(float(weighted["weight"].sum()), 3))
print("min / max weight =", round(weighted["weight"].min(), 3), "/", round(weighted["weight"].max(), 3))
print("Kish deff =", round(design_effect(pipe.result), 3))
print("converged =", pipe.diagnostics["steps"]["calibrate"]["converged"])
print("iterations =", pipe.diagnostics["steps"]["calibrate"]["iterations"])
weighted[["weight"]].describe().Tn = 1512
sum(weight) = 1512.0
min / max weight = 0.332 / 4.304
Kish deff = 1.38
converged = True
iterations = 12
count mean std min 25% 50% 75% max
weight 1512.000 1.000 0.616 0.332 0.618 0.804 1.105 4.304
Kish’s approximate design effect from unequal weighting is again about 1.38 — the same figure as in the original R post. Weighting loss is \(L_w = \mathrm{deff} - 1 \approx 0.38\), under the usual caveats (no clustering in this approximation).
Checking margins after raking
The unweighted sample was off on several margins (for example, sex was about 40.7% / 59.3% versus population targets 49% / 51%). After raking, you can check what the weighted sample actually matches. Calibrate diagnostics include a tidy margin_table; pipe.margins(...) recomputes the same idea from the current weights (so it still works after trim):
# Built-in after calibrate:
pipe.diagnostics["steps"]["calibrate"]["margin_table"].head()
# Anytime (here: reuse the rake targets, including after trim):
check = pipe.margins(targets="calibrate")
check.loc[
check["variable"] == "sex",
["category", "target_proportion", "achieved_proportion", "abs_diff"],
].round(3) category target_proportion achieved_proportion abs_diff
0 1 0.490 0.490 0.000
1 2 0.510 0.510 0.000
Across all raking variables, the largest absolute difference from the targets is essentially zero (on the order of \(10^{-7}\)), including after the ratio-5 trim with redistribution. Call pipe.margins("sex") without targets anytime you only want the current weighted distribution.
Approval estimates with bootstrap CIs
Approval codes: 1 = approve, 2 = disapprove, 3 = unsure, 9 = don’t know. Use pipe.estimate.proportion on binary indicators instead of a custom weighted tabulation. With no strata/PSU in the CEP file, this is an unequal-weight bootstrap that re-runs the full weighting cascade in each replicate.
Passing a list of indicators returns one row per variable and builds the replicate weights once, so all four categories come from a single call.
pipe.estimate.proportion(
"approve",
variance="bootstrap",
replicates=400,
seed=42,
).round(3) estimand variable estimate se cv ci_lower ci_upper level R_used variance design
0 proportion approve 0.298 0.013 0.045 0.272 0.325 0.950 400 bootstrap custom
approval_ci = pipe.estimate.proportion(
["approve", "disapprove", "unsure", "dk"],
variance="bootstrap",
replicates=400,
seed=42,
)
approval_ci[["variable", "estimate", "se", "ci_lower", "ci_upper"]].round(3) variable estimate se ci_lower ci_upper
0 approve 0.298 0.013 0.272 0.325
1 disapprove 0.521 0.013 0.494 0.547
2 unsure 0.161 0.010 0.141 0.181
3 dk 0.020 0.003 0.013 0.027
Raking on top of existing survey weights
The CEP file includes pond weights (max ≈ 17.6). As before, documentation of how they were built is thin. We can still use them as the base weight and rake (here only on ses and region, the margins that were most off after applying pond in the 2012 analysis).
print("pond summary")
print(dat["pond"].describe().round(3).to_string())
print("Kish deff (pond) =", round(design_effect(dat["pond"]), 3))
pipe_pond = (
WeightPipe(dat, weight="pond")
.options(warn=False)
.calibrate(
method="raking",
proportions={"ses": proportions["ses"], "region": proportions["region"]},
max_iter=100,
tol=1e-8,
)
.trim(max_ratio=5.0, reference="value", redistribute=True)
)
weighted_pond = pipe_pond.collect_weights()
print("Kish deff (raked pond) =", round(design_effect(pipe_pond.result), 3))
print("min / max weight =", round(weighted_pond["weight"].min(), 3), "/", round(weighted_pond["weight"].max(), 3))pond summary
count 1512.000
mean 1.000
std 1.044
min 0.015
25% 0.455
50% 0.786
75% 1.235
max 17.563
Kish deff (pond) = 2.088
Kish deff (raked pond) = 1.81
min / max weight = 0.055 / 5.0
approval_pond_ci = pipe_pond.estimate.proportion(
["approve", "disapprove", "unsure", "dk"],
variance="bootstrap",
replicates=400,
seed=42,
)
approval_pond_ci[["variable", "estimate", "se", "ci_lower", "ci_upper"]].round(3) variable estimate se ci_lower ci_upper
0 approve 0.292 0.013 0.266 0.319
1 disapprove 0.531 0.014 0.504 0.557
2 unsure 0.154 0.013 0.128 0.179
3 dk 0.023 0.004 0.015 0.031
The results are similar to those in the R example. Raking from uniform weights and raking from pond produce comparable approval estimates, and their bootstrap intervals overlap.
Nonresponse adjustment, then raking
The CEP file contains respondents only; sample cases that did not answer are not available. To illustrate nonresponse adjustment followed by calibration, I keep the CEP observations as respondents and add simulated nonrespondents. This is only an illustration of the method, not a claim about CEP fieldwork.
weightpipe supports two nonresponse methods:
-
weighting_class— inflate respondents withinby=cells and set nonrespondent weights to zero. -
propensity— fit a response model withengine="logit"(default),"gbm", or"forest", then use propensity classes (num_classes=5) or direct inverse-propensity factors (num_classes=None).
rng = np.random.default_rng(42)
n_nr = 400
nonrespondents = pd.DataFrame(
{
"sex": rng.choice([1, 2], size=n_nr, p=[0.65, 0.35]),
"agecat": rng.choice([1, 2, 3, 4, 5], size=n_nr, p=[0.25, 0.25, 0.20, 0.15, 0.15]),
"ses": rng.choice([1, 2, 3, 4, 5], size=n_nr, p=[0.05, 0.10, 0.30, 0.40, 0.15]),
"region": rng.choice(list(range(1, 16)), size=n_nr),
"area": rng.choice([1, 2], size=n_nr, p=[0.70, 0.30]),
"approval": np.nan,
"responded": 0,
}
)
respondents = dat.copy()
respondents["responded"] = 1
for code, name in [(1, "approve"), (2, "disapprove"), (3, "unsure"), (9, "dk")]:
respondents[name] = (respondents["approval"] == code).astype(int)
nonrespondents[name] = 0
frame = pd.concat([respondents, nonrespondents], ignore_index=True)
print("n_sample =", len(frame))
print("n_respondents =", int(frame["responded"].sum()))
print("response rate =", round(float(frame["responded"].mean()), 3))n_sample = 1912
n_respondents = 1512
response rate = 0.791
Weighting-class nonresponse
pipe_nr = (
WeightPipe(frame)
.options(min_cell_n=1, warn=False)
.nonresponse(
respondent="responded",
method="weighting_class",
by=["sex", "agecat", "area"],
)
.calibrate(
method="raking",
proportions=proportions,
max_iter=100,
tol=1e-8,
)
.trim(max_ratio=5.0, reference="value", redistribute=True)
)
weighted_nr = pipe_nr.collect_weights(keep_intermediate=True, drop_zero=True)
factors = weight_factors(pipe_nr.result)
print("active units =", len(weighted_nr))
print("sum(weight) =", round(float(weighted_nr["weight"].sum()), 3))
print("Kish deff =", round(design_effect(pipe_nr.result), 3))
factors.loc[frame["responded"] == 1, ["factor_nonresponse"]].describe().T.round(3)active units = 1512
sum(weight) = 1912.0
Kish deff = 1.383
count mean std min 25% 50% 75% max
factor_nonresponse 1512.000 1.265 0.292 1.047 1.099 1.140 1.385 3.545
pipe_nr.estimate.proportion(
"approve",
variance="bootstrap",
replicates=200,
seed=42,
).round(3) estimand variable estimate se cv ci_lower ci_upper level R_used variance design
0 proportion approve 0.297 0.012 0.040 0.274 0.320 0.950 200 bootstrap custom
Logistic propensity nonresponse
I now repeat the adjustment using a logistic response model with sex, agecat, and area as predictors. With num_classes=5, observations are grouped by their predicted \(\hat{p}\) and adjusted within classes:
pipe_prop = (
WeightPipe(frame)
.options(min_cell_n=1, warn=False)
.nonresponse(
respondent="responded",
method="propensity",
engine="logit",
formula="~ sex + agecat + area",
num_classes=5,
)
.calibrate(
method="raking",
proportions=proportions,
max_iter=100,
tol=1e-8,
)
.trim(max_ratio=5.0, reference="value", redistribute=True)
)
factors_prop = weight_factors(pipe_prop.result)
print("Kish deff =", round(design_effect(pipe_prop.result), 3))
print(
"mean propensity =",
round(pipe_prop.diagnostics["steps"]["nonresponse"]["mean_propensity"], 3),
)
factors_prop.loc[frame["responded"] == 1, ["factor_nonresponse"]].describe().T.round(3)Kish deff = 1.381
mean propensity = 0.791
count mean std min 25% 50% 75% max
factor_nonresponse 1512.000 1.265 0.216 1.047 1.101 1.193 1.373 1.679
pipe_prop.estimate.proportion(
"approve",
variance="bootstrap",
replicates=200,
seed=42,
).round(3) estimand variable estimate se cv ci_lower ci_upper level R_used variance design
0 proportion approve 0.297 0.012 0.040 0.274 0.321 0.950 200 bootstrap custom
For direct inverse-propensity weighting, use num_classes=None (factor = \(1/\hat{p}\) for respondents). In this example, that gives deff ≈ 1.377 and approval ≈ 0.298. The point estimate is similar to those from weighting classes and propensity classes, although the factors have different dispersion.
We can replace engine="logit" with engine="gbm" or engine="forest" when the response process is likely to be nonlinear. Here, the three engines give almost identical results (Kish deff ≈ 1.38), so I prefer the more interpretable logistic model.
Keeping propensity classes while raking
Demographic raking after a propensity adjustment can redistribute weight across propensity classes. To avoid this, I use assist="propensity_class". It adds the post-nonresponse class totals as another raking margin, preserving them while matching the demographic targets. I can pass the same proportions= used above; weightpipe scales them to the current weight total before adding the class totals.
pipe_assist = (
WeightPipe(frame)
.options(min_cell_n=1, warn=False)
.nonresponse(
respondent="responded",
method="propensity",
engine="logit",
formula="~ sex + agecat + area",
num_classes=5,
)
.calibrate(
method="raking",
proportions=proportions,
assist="propensity_class",
max_iter=100,
tol=1e-8,
)
.trim(max_ratio=5.0, reference="value", redistribute=True)
)
print("Kish deff =", round(design_effect(pipe_assist.result), 3))
pipe_assist.estimate.proportion(
"approve",
variance="bootstrap",
replicates=200,
seed=42,
).round(3)Kish deff = 1.447
estimand variable estimate se cv ci_lower ci_upper level R_used variance design
0 proportion approve 0.299 0.013 0.042 0.274 0.324 0.950 200 bootstrap custom
Before trimming, the five propensity-class weight totals exactly match the post-nonresponse totals. Trimming can change them slightly, but the assisted calibration itself preserves the nonresponse adjustment. The approval estimate remains close to the unassisted result (~0.297), although the Kish design effect is modestly higher (~1.45).
Putting the steps together
The complete workflow can be summarized as follows:
from weightpipe import WeightPipe, design_effect
pipe = (
WeightPipe(frame) # or respondents-only `dat`
# With a design: WeightPipe(frame, weight="pw", psu="psu", strata="stratum")
.nonresponse(
respondent="responded",
method="weighting_class", # or propensity + engine="logit"|"gbm"|"forest"
by=["sex", "agecat", "area"],
)
.calibrate(
method="raking",
proportions=proportions, # + assist="propensity_class" after propensity NR
)
.trim(max_ratio=5.0, reference="value", redistribute=True)
)
pipe.collect_weights()
design_effect(pipe.result)
pipe.estimate.proportion(
"approve", variance="bootstrap", replicates=400, seed=42
)
If strata and PSUs are available, pass them when constructing the pipe (psu=, strata=). estimate then uses them for bootstrap or jackknife variance. Other examples in the weightpipe documentation cover eligibility adjustments, linear/GREG calibration, design-based estimation and GLMs, and sample-size planning.
Related: Raking weights with R (2012)