This document describes the internal architecture of the balance Python package. For usage and API documentation, see the README and import-balance.org.
For LLM/AI coding assistant instructions, see .github/copilot-instructions.md.
The core object model uses a three-class inheritance hierarchy:
object
/ \
/ \
┌─────────────────┐ ┌─────────────────┐
│ SampleFrame │ │ BalanceFrame │
│ (1373 lines) │ │ (1918 lines) │
│ │ │ │
│ DataFrame + │◄─ ─ ─ ─ ┤ Adjustment │
│ column-role │ composes │ orchestrator │
│ metadata │ (via │ (sample+target)│
│ │_sf_sample)│ │
└────────┬────────┘ └────────┬────────┘
\ /
\ MULTIPLE /
\ INHERITANCE /
\ /
┌───┴──────────────────┴───┐
│ Sample │
│ (240 lines) │
│ │
│ class Sample( │
│ BalanceFrame, │
│ SampleFrame): │
│ │
│ Thin backward- │
│ compatible facade │
└──────────────────────────┘
MRO: Sample → BalanceFrame → SampleFrame → object
Key: BalanceFrame does NOT inherit from SampleFrame.
It COMPOSES a SampleFrame instance via _sf_sample.
Sample inherits from BOTH via multiple inheritance.
Sample(sample_class.py) — main user-facing object, constructed viafrom_frame()factory. Internally a thin inheritance wrapper:class Sample(BalanceFrame, SampleFrame). All public API unchanged —from_frame(),set_target(),adjust(),summary(),covars(),weights(),outcomes(), etc. all work identically.SampleFrame(sample_frame.py) — DataFrame container with explicit column-role metadata (covars, weights, outcomes, outcomes_hat, ignored). Created viaSampleFrame.from_frame(). Provides weight management methods (add_weight_column(),set_active_weight(),rename_weight_column(),set_weight_metadata()),set_weights(), andtrim().BalanceFrame(balance_frame.py) — adjustment orchestrator pairing a responderSampleFramewith a targetSampleFrame. Handlesadjust(),summary(),diagnostics(),covars(),weights(),outcomes(),set_weights()(delegates to_sf_sample),trim()(delegates to_sf_sample), and all linked-source comparisons. Also exposes sklearn-style convenience methods for IPW workflows:fit(),design_matrix(),predict_proba(), andpredict_weights(). Supports compound/sequential adjustments with unified weight history tracking.BalanceDFhierarchy (balancedf_class.py) — role-specific views:BalanceDFCovars— covariate access and statisticsBalanceDFWeights— weight diagnostics (design effect, density plots)BalanceDFOutcomes— outcome analysisBalanceDFOutcomesHat— predicted-outcome (Ŷ) analysis (weighted mean / CI over theoutcomes_hatcolumns); created via theoutcomes_hat()factory onSampleFrame/BalanceFrame(returnsNonewhen there are no Ŷ columns) and exported frombalanceBalanceDFSource(protocol) — 8 required members:weight_series,id_series,_links,_covar_columns(),_outcome_columns,_outcomes_hat_columns,set_weights(),trim()
All data-access properties follow a consistent naming pattern:
| Suffix | Returns | Examples |
|---|---|---|
*_column |
Column name (str) |
id_column, weight_column |
*_series |
Column data (pd.Series) |
id_series, weight_series |
*_columns |
List of names (list[str]) |
covar_columns, outcome_columns, outcomes_hat_columns, weight_columns_all |
df_* |
DataFrame | df_covars, df_weights, df_outcomes, df_outcomes_hat, df_ignored |
Note: the _* protocol accessors _outcome_columns and _outcomes_hat_columns return the column data (a DataFrame | None), not names — the names live on outcome_columns / outcomes_hat_columns.
Migration warnings (FutureWarning, will be removed after 2026-06-01):
id_column— changed in 0.20.0 from returning data to returning the name. Useid_seriesfor data.weight_column— changed in 0.19.0 from returning data to returning the name. Useweight_seriesfor data.
┌──────────────────────────┬──────────────────────────────┐
│ Responsibility │ Class │
├──────────────────────────┼──────────────────────────────┤
│ DataFrame storage │ SampleFrame._df │
│ Column-role metadata │ SampleFrame._column_roles │
│ outcomes_hat (Ŷ) data │ SampleFrame (canonical) │
│ (df_outcomes_hat / │ add_outcomes_hat_column(); │
│ _outcomes_hat_columns) │ BalanceFrame delegates │
│ Fit outcome model ĝ(X) │ SampleFrame.fit_outcome_model│
│ (store on frame) │ → _outcome_model / │
│ │ outcome_model (property) │
│ Predict/persist Ŷ │ SampleFrame.predict_outcomes │
│ from stored model │ / fit_predict_outcomes │
│ Fit outcome model via BF │ BalanceFrame.fit_outcome_model│
│ (delegate to responder) │ / fit_predict_outcomes │
│ │ → _sf_sample._outcome_model │
│ outcome_model (read) │ BalanceFrame (property, │
│ │ delegates to _sf_sample) │
│ Transfer ĝ to the target │ BalanceFrame.predict_outcomes│
│ produce Ŷ_T │ (on="target"; deep-copies │
│ │ _sf_target before writing) │
│ Apply a foreign fitted │ BalanceFrame. │
│ model (train/holdout) │ set_fitted_outcome_model │
│ │ (shares fit by identity) │
│ Outcome-model estimate │ outcomes_hat().mean() │
│ μ̂_OM (target row) │ (raises if target Ŷ unpop.) │
│ IPW/Hájek estimate μ̂_IPW │ outcomes().mean() (self) │
│ AIPW estimate μ̂_DR │ BalanceFrame.aipw() │
│ (doubly robust) │ (μ̂_OM + IPW-wtd residuals) │
│ ID/weight columns │ SampleFrame │
│ Type standardization │ SampleFrame.from_frame() │
│ Weight management │ SampleFrame (canonical) │
│ (add/set/rename/trim) │ BalanceFrame delegates │
│ set_weights() │ SampleFrame (canonical) │
│ │ BalanceFrame delegates to │
│ │ _sf_sample.set_weights() │
│ trim() │ SampleFrame (canonical) │
│ │ BalanceFrame delegates │
│ covars()/weights()/etc. │ BalanceFrame │
│ set_target() │ BalanceFrame │
│ adjust() │ BalanceFrame │
│ _build_adjusted_frame() │ BalanceFrame │
│ _next_weight_action_no() │ BalanceFrame (shared counter │
│ │ for adjusted_N/trimmed_N) │
│ summary()/diagnostics() │ BalanceFrame (→summary_utils)│
│ has_target/is_adjusted │ BalanceFrame (_CallableBool) │
│ _links dict │ BalanceFrame │
│ │ (defaultdict(list)) │
│ model │ BalanceFrame (property) │
│ to_csv()/to_download() │ BalanceFrame │
│ model_matrix() │ BalanceFrame │
│ Construction guard │ Sample.__new__ │
│ Factory method │ Sample.from_frame() │
│ │ → SampleFrame.from_frame() │
│ │ → cls._create() │
└──────────────────────────┴──────────────────────────────┘
from balance import Sample
# 1. Create Sample objects
sample = Sample.from_frame(sample_df, id_column="id", outcome_columns="outcome")
target = Sample.from_frame(target_df, id_column="id", weight_column="count")
# 2. Link sample to target population
sample = sample.set_target(target)
# 3. Pre-adjustment diagnostics
sample.covars().plot() # Visual covariate balance check
# 4. Adjust (weight)
adjusted = sample.adjust(
variables=["age", "gender", "os"],
method="ipw", # or "cbps", "poststratify", "rake"
max_de=2, # cap design effect (ipw/cbps only)
)
# 5. Post-adjustment evaluation
adjusted.summary() # Summary table
adjusted.covars().plot() # Post-adjustment balance
adjusted.covars().asmd() # ASMD per covariate
adjusted.weights().design_effect() # Variance inflation factoradjust() can be called multiple times. Each call uses the previous step's weights as design weights, enabling multi-stage reweighting pipelines. Internally, _build_adjusted_frame() manages a unified weight history:
| After | Weight columns in _df |
Active |
|---|---|---|
| Before adj. | weight | weight |
| 1st adjust | weight, weight_pre_adjust, weight_adjusted_1 | weight |
| 2nd adjust | weight, weight_pre_adjust, weight_adjusted_1, _2 | weight |
| After trim | ... weight_adjusted_1, _2, weight_trimmed_3 | weight |
weight_pre_adjust— frozen copy of original design weights (1st adjustment only)weight_adjusted_N— output of the Nth adjustment stepweight_trimmed_N— output of the Nth trim stepweight— always overwritten with the latest values (keeps its original name)_next_weight_action_number()— shared counter acrossweight_adjusted_Nandweight_trimmed_N
For compound adjustments, _sf_sample_pre_adjust always points to the very first baseline, and _links["unadjusted"] chains back through the full adjustment history.
BalanceFrame.fit(method="ipw") is an alias for adjust(...) that enables
store_fit_matrices=True and store_fit_metadata=True by default for the built-in
IPW method. By default fit() mutates self and returns self (sklearn-style
inplace=True); pass inplace=False for functional-style usage that returns
a new object. This stores fit-time artifacts in model so downstream calls can
reuse the exact training transformation/predictions without recomputing preprocessing:
design_matrix(on=...)→ stored model matrices (IPW only)predict_proba(on=..., output=...)→ stored probabilities or link values (IPW only)predict_weights()→ dispatches by method; IPW uses stored links + design weights
set_fitted_model(fitted) applies a fitted model from one BalanceFrame to another,
producing a fully adjusted holdout BalanceFrame for train/holdout-split workflows.
predict_weights() dispatches by the
model's method key, currently supporting "ipw" with extensibility for future
methods (CBPS, rake, poststratify).
When these artifacts are not stored (e.g. plain adjust(method="ipw")), the API
raises actionable errors that direct users to fit(method="ipw") or the explicit
ipw(..., store_fit_matrices=True/store_fit_metadata=True) flags.
The outcome_models/ package is the outcome-modelling counterpart to the IPW
fit-artifact workflow, on a separate axis: instead of a propensity model over a
sample-vs-target indicator, it fits a learner ĝ(X) ≈ E[Y|X] of an observed
outcome on covariates. The package ships pure DataFrame functions, and
SampleFrame wires them onto a frame as an sklearn-style trio (the standalone,
no-target fit/store step):
fit_outcome_model(covars_df, outcomes_df, *, sample_weight=None, model="auto", ...)builds a design matrix viabuild_design_matrix(train mode), fits a regressor (continuous outcome) or classifier (binary outcome) per outcome column, and returns a stored model dict (method="outcome_model",fit,X_matrix_columns,fit_scaler,categorical_levels,fit_matrix_type,weighted,prediction_kind,perf, …) mirroring the IPW model dict so the same replay works.predict_outcome(model, new_covars_df)rebuilds the design matrix in replay mode (project_to_columns+ stored scaler + re-appliedcategorical_levels) and returnsŷper outcome (.predictfor a regressor,P̂(Y=1)for a classifier).
Preprocessing is learner-dependent (use_model_matrix="auto"): tree/boosting learners
use the native-categorical path on scikit-learn >= 1.4 (one-hot fallback on < 1.4),
linear learners use one-hot + StandardScaler; the matrix is densified for
HistGradientBoosting*.
On top of these primitives, SampleFrame (and, via the MRO, Sample) exposes the
sklearn-style trio, which stores the fitted model on the frame:
fit_outcome_model(*, model="auto", outcome_columns=None, variables=None, weighted=False, ..., inplace=True)resolves the outcome column(s) (default: alloutcome_columns), extractsdf_covars(optionally restricted to thevariables=subset) and the observed outcome(s), drops rows with a missing outcomeY(covariates/weights realign), and — whenweighted=True(the default is unweighted) — aligns the active weight to the covariate index, fits viafit_outcome_model, and stores the model dict on_outcome_model(exposed via the read-onlyoutcome_modelproperty, mirroringBalanceFrame.model). Like sklearn'sfit, it does not persistoutcomes_hat; a re-fit drops any<outcome>_hatcolumns a priorpredict_outcomesleft behind so a stale Ŷ can't linger against a new model.predict_outcomes(*, data=None, populate=True)replays the stored model on this frame's covariates (or ondata's covariates when aSampleFrameis passed) and returns a{"<outcome>_hat": ŷ}DataFrame, persisting the<outcome>_hatcolumns viaadd_outcomes_hat_columnwhenpopulate=True.fit_predict_outcomes(*, populate=True, **fit_kwargs)fits then predicts-on-self in one call.
The new _outcome_model frame state is initialised in SampleFrame._create, reference-shares
its fitted estimators on SampleFrame.__deepcopy__ (immutable post-fit — the dict is shallow
copied, the estimators are kept by reference), and is synced onto Sample via
BalanceFrame._sync_sampleframe_state_from_responder (mirroring _prediction_metadata).
BalanceFrame orchestrates the transfer to the target and the estimate (the counterpart to
how it orchestrates adjust()/set_fitted_model() for weights). It delegates the fit to the
responder so the model has a single home that rides the lifecycle:
BalanceFrame.fit_outcome_model(*, target=None, inplace=True, **kw)/fit_predict_outcomes(...)call_sf_sample.fit_outcome_model(...), so the model lands on_sf_sample._outcome_model. Because aSampleis both aBalanceFrameand aSampleFrame, thisBalanceFramemethod takes MRO precedence overSampleFrame.fit_outcome_model, so a model fit on aSamplelands on_sf_sample(not the Sample's own inherited attribute) — this is what lets it surviveadjust()(which deep-copies_sf_sample).BalanceFrame.outcome_modelis a read-only property that delegates to_sf_sample(single source of truth; mirrorsdf_outcomes_hat/_outcomes_hat_columns, and parallelsBalanceFrame.modelfor the weighting axis).BalanceFrame.predict_outcomes(*, on="sample"|"target"|"both", populate=True)replays the responder's stored model.on="target"(the default when a target is set) scores the target's covariates and populates its<outcome>_hatcolumns, deep-copying_sf_targetbefore writing so the caller's target object is not mutated in place;on="both"returns a(sample, target)tuple (mirroringpredict_weights/design_matrix).BalanceFrame.set_fitted_outcome_model(fitted, *, inplace=True)is the train/holdout transfer — the outcome-axis counterpart toset_fitted_modelfor weights. It copies an already-fitted outcome model from another frame (fitted, aBalanceFrame/SampleFrame/Sample) ontoself's responder sharing the fitted estimators by identity (a shallow copy of the model dict, not a refit or deep clone —scored.outcome_model["fit"][c] is train.outcome_model["fit"][c]), so a subsequentpredict_outcomes(on="target")replays the transferred model onself's own target forμ̂_OMon the holdout. It reuses the same"matching sample covariate column names"check asset_fitted_model, and rejects a non-deterministic transfer (a storedtransformationsofquantize/fct_lump, orna_action="drop") that can't be replayed deterministically on a foreign frame;inplacematchesset_fitted_model.- The estimate is
μ̂_OM = outcomes_hat().mean()— the target row =Σ w_T ŷ_T / Σ w_T.BalanceFrame.outcomes_hat()builds the view when either the responder or a linked source (target/unadjusted) carriesoutcomes_hat, and it raises an actionable error (pointing atpredict_outcomes(on="target")) when a model is fit but the target'soutcomes_hatis not populated, so the population estimate is never silently replaced by the responder's in-sample mean. - Honest inference:
outcomes_hat().mean_with_ci(ci_method="bootstrap", n_bootstrap=200, random_seed=2020)(the defaultci_method) computes a percentile CI forμ̂_OMvia a nonparametric bootstrap — resample the responders, refitĝ*with the stored fit-configuration (and fit-weighting), predict on the fixed target, re-average — capturing the outcome-model estimation uncertainty the analyticci_of_weighted_meanignores. The reusable engine (bootstrap_outcome_estimate) lives inoutcome_models/and keeps only theBscalar outputs; the override is a bespoke path on the BalanceFrame-backed view (it bypasses the linked-view machinery, which can't reach the learner) and raises on a lone/target-less view. Deterministic givenrandom_seed(numpy.random.default_rng). outcomes_hat().summary()reports the estimator type and scopes any doubly-robust statement to the fit weights — never a blanket "doubly robust": a linear + intercept learner fit with non-uniform weights is reported as"doubly robust w.r.t. weights <col>"(the WLS special case), everything else (the non-linear default, a uniform-weight fit, or a no-intercept linear fit) as plain"g-computation (not doubly robust)".
Lifecycle: set_target() preserves _outcome_model across its responder reset (a model fit
before or after adjust() is not lost when the target is replaced); keep_only_some_rows_columns
that drops responder rows invalidates the model (its training_sample_index no longer matches
the retained rows), while a column-only filter keeps it. A doubly-robust / AIPW estimator combining
outcomes_hat with the IPW weights is a later phase. See the design doc
architecture_0_23_0.md.
| Method | File | When to use |
|---|---|---|
| IPW | ipw.py |
Default. Lasso-regularized logistic regression propensity scoring |
| CBPS | cbps.py |
Recommended for production. Directly optimizes covariate balance |
| Rake | rake.py |
When you only have marginal distributions (not joint) |
| Poststratify | poststratify.py |
When you have population cell counts (joint distribution). Categorical variables only |
| Null | adjust_null.py |
Passthrough (no adjustment) |
Key parameters across methods: max_de (design effect cap, default 1.5), transformations (override auto-transformations), weight_trimming_mean_ratio (trim extreme weights), na_action (handle NAs).
stats_and_plots/— statistical summaries (weighted mean/var/sd/quantile, weighted R² viaweighted_r2), weighted comparisons (ASMD), plots (seaborn/plotly/ASCII)utils/— data transformations, input validation, model matrix (patsy), pandas helpers, file/logging utilsdatasets/— simulated data generators and sample CSVsadjustment.py— weight trimming (mean ratio, percentile winsorization)cli.py— command-line interface (BalanceCLI)summary_utils.py— diagnostics and summary builders (_build_summary(),_build_diagnostics()), extracted fromsample_class.pytestutil.py— test fixtures and helpers
In the open-source repo, the top-level structure is: balance/ (package source), tests/, tutorials/, website/, pyproject.toml, CHANGELOG.md.
Within balance/, the core files are: sample_class.py (Sample), sample_frame.py (SampleFrame), balance_frame.py (BalanceFrame), balancedf_class.py (BalanceDF views), adjustment.py (weight trimming), cli.py, summary_utils.py, util.py, typing.py.
Subdirs: weighting_methods/, outcome_models/ (outcome-model learner + fit/predict), stats_and_plots/, utils/, datasets/.
- Three-class architecture deep dive: Detailed diagrams of the class hierarchy, column classification, object lifecycle, BalanceDF expansion, and data flow.