balance Quickstart: Analyzing and adjusting the bias on a simulated toy dataset¶

'balance' is a Python package that is maintained and released by the Core Data Science Tel-Aviv team in Meta. 'balance' performs and evaluates bias reduction by weighting for a broad set of experimental and observational use cases.

Although balance is written in Python, you don't need a deep Python understanding to use it. In fact, you can just use this notebook, load your data, change some variables and re-run the notebook and produce your own weights!

This quickstart demonstrates re-weighting specific simulated data, but if you have a different usecase or want more comprehensive documentation, you can check out the comprehensive balance tutorial.

Analysis¶

There are four main steps to analysis with balance:

  • load data
  • check diagnostics before adjustment
  • perform adjustment + check diagnostics
  • output results

Let's dive right in!

Example dataset¶

The following is a toy simulated dataset.

In [1]:
%matplotlib inline

import plotly.offline as offline
offline.init_notebook_mode()

import warnings
warnings.filterwarnings("ignore")

from balance import load_data
INFO (2026-10-10 13:50:04,275) [__init__/<module> (line 76)]: Using balance version 0.23.0
INFO (2026-10-10 13:50:04,276) [__init__/<module> (line 81)]: 
balance (Version 0.23.0) loaded:
    📖 Documentation: https://import-balance.org/
    🛠️ Help / Issues: https://github.com/facebookresearch/balance/issues/
    📄 Citation:
        Sarig, T., Galili, T., & Eilat, R. (2023).
        balance - a Python package for balancing biased data samples.
        https://arxiv.org/abs/2307.06024

    Tip: You can view this message anytime with balance.help()

In [2]:
target_df, sample_df = load_data()

print("target_df: \n", target_df.head())
print("sample_df: \n", sample_df.head())
target_df: 
        id gender age_group     income  happiness
0  100000   Male       45+  10.183951  61.706333
1  100001   Male       45+   6.036858  79.123670
2  100002   Male     35-44   5.226629  44.206949
3  100003    NaN       45+   5.752147  83.985716
4  100004    NaN     25-34   4.837484  49.339713
sample_df: 
   id  gender age_group     income  happiness
0  0    Male     25-34   6.428659  26.043029
1  1  Female     18-24   9.940280  66.885485
2  2    Male     18-24   2.673623  37.091922
3  3     NaN     18-24  10.550308  49.394050
4  4     NaN     18-24   2.689994  72.304208
In [3]:
target_df.head().round(2).to_dict()
# sample_df.shape
Out[3]:
{'id': {0: '100000', 1: '100001', 2: '100002', 3: '100003', 4: '100004'},
 'gender': {0: 'Male', 1: 'Male', 2: 'Male', 3: nan, 4: nan},
 'age_group': {0: '45+', 1: '45+', 2: '35-44', 3: '45+', 4: '25-34'},
 'income': {0: 10.18, 1: 6.04, 2: 5.23, 3: 5.75, 4: 4.84},
 'happiness': {0: 61.71, 1: 79.12, 2: 44.21, 3: 83.99, 4: 49.34}}

In practice, one can use pandas loading function(such as read_csv()) to import data into the DataFrame objects sample_df and target_df.

Load data into a Sample object¶

The first thing to do is to import the Sample class from balance. All of the data we're going to be working with, sample or population, will be stored in objects of the Sample class.

In [4]:
from balance import Sample

Using the Sample class, we can fill it with a "sample" we want to adjust, and also a "target" we want to adjust towards.

We turn the two input pandas DataFrame objects we created (or loaded) into a balance.Sample objects, by using the .from_frame()

In [5]:
sample = Sample.from_frame(sample_df, outcome_columns=["happiness"])
# Often times we don't have the outcome for the target. In this case we've added it just to validate later that the weights indeed help us reduce the bias
target = Sample.from_frame(target_df, outcome_columns=["happiness"])
WARNING (2026-10-10 13:50:04,318) [input_validation/guess_id_column (line 335)]: Guessed id column name id for the data
WARNING (2026-10-10 13:50:04,329) [sample_frame/from_frame (line 379)]: No weights passed. Adding a 'weight' column and setting all values to 1
WARNING (2026-10-10 13:50:04,331) [input_validation/guess_id_column (line 335)]: Guessed id column name id for the data
WARNING (2026-10-10 13:50:04,346) [sample_frame/from_frame (line 379)]: No weights passed. Adding a 'weight' column and setting all values to 1

If we use the .df property call, we can see the DataFrame stored in sample. We can see how we have a new weight column that was added (it will all have 1s) in the importing of the DataFrames into a balance.Sample object.

In [6]:
sample.df.info()
<class 'pandas.DataFrame'>
RangeIndex: 1000 entries, 0 to 999
Data columns (total 6 columns):
 #   Column     Non-Null Count  Dtype  
---  ------     --------------  -----  
 0   id         1000 non-null   str    
 1   gender     912 non-null    str    
 2   age_group  1000 non-null   str    
 3   income     1000 non-null   float64
 4   happiness  1000 non-null   float64
 5   weight     1000 non-null   float64
dtypes: float64(3), str(3)
memory usage: 47.0 KB

We can get a quick overview text of each Sample object, by just calling it.

Let's take a look at what this produces:

In [7]:
sample
Out[7]:
(balance.sample_class.Sample)

        balance Sample object
        1000 observations x 3 variables: gender,age_group,income
        id_column: id, weight_column: weight,
        outcome_columns: happiness
        
In [8]:
target
Out[8]:
(balance.sample_class.Sample)

        balance Sample object
        10000 observations x 3 variables: gender,age_group,income
        id_column: id, weight_column: weight,
        outcome_columns: happiness
        

Next, we combine the sample object with the target object. This is what will allow us to adjust the sample to the target.

In [9]:
sample_with_target = sample.set_target(target)

Looking on sample_with_target now, it has the target attached:

In [10]:
sample_with_target
Out[10]:
(balance.sample_class.Sample)

        balance Sample object with target set
        1000 observations x 3 variables: gender,age_group,income
        id_column: id, weight_column: weight,
        outcome_columns: happiness
        
            target:
                 
	        balance Sample object
	        10000 observations x 3 variables: gender,age_group,income
	        id_column: id, weight_column: weight,
	        outcome_columns: happiness
	        
            3 common variables: gender,age_group,income
            

Pre-Adjustment Diagnostics¶

We can use .covars() and then followup with .mean() and .plot() (barplots and kde density plots) to get some basic diagnostics on what we got.

We can see how:

  • The proportion of missing values in gender is similar in sample and target.
  • We have younger people in the sample as compared to the target.
  • We have more females than males in the sample, as compared to around 50-50 split for the (non NA) target.
  • Income is more right skewed in the target as compared to the sample.
In [11]:
print(sample_with_target.covars().mean().T)
source                     self     target
_is_na_gender[T.True]  0.088000   0.089800
age_group[T.25-34]     0.300000   0.297400
age_group[T.35-44]     0.156000   0.299200
age_group[T.45+]       0.053000   0.206300
gender[Female]         0.268000   0.455100
gender[Male]           0.644000   0.455100
gender[_NA]            0.088000   0.089800
income                 6.297302  12.737608
In [12]:
print(sample_with_target.covars().asmd().T)
source                  self
age_group[T.25-34]  0.005688
age_group[T.35-44]  0.312711
age_group[T.45+]    0.378828
gender[Female]      0.375699
gender[Male]        0.379314
gender[_NA]         0.006296
income              0.494217
mean(asmd)          0.326799
In [13]:
print(sample_with_target.covars().asmd(aggregate_by_main_covar = True).T)
source          self
age_group   0.232409
gender      0.253769
income      0.494217
mean(asmd)  0.326799

Distribution diagnostics (KLD/EMD/CVMD/KS)¶

Balance also exposes distribution diagnostics for covariates. These look beyond mean differences and compare the full distributions of covariates in the weighted sample vs. the target.

  • KLD (Kullback-Leibler divergence) measures the relative entropy between two probability distributions (note: this is a divergence measure, not a symmetric distance). (See: https://en.wikipedia.org/wiki/Kullback%E2%80%93Leibler_divergence)
  • EMD (Earth Mover's Distance) measures the minimum "cost" to transform one distribution into another. (See: https://en.wikipedia.org/wiki/Earth_mover%27s_distance)
  • CVMD (Cramér–von Mises distance) measures the integrated squared difference between the empirical CDFs. (See: https://en.wikipedia.org/wiki/Cram%C3%A9r%E2%80%93von_Mises_criterion)
  • KS (Kolmogorov–Smirnov distance) measures the maximum absolute difference between the empirical CDFs. (See: https://en.wikipedia.org/wiki/Kolmogorov%E2%80%93Smirnov_test)

Note: Distribution diagnostics operate on the raw covariates (with NA indicators), rather than the model matrix, so categorical variables stay intact.

These diagnostics complement ASMD, which only compares means. Use these metrics when you want to check whether weighting aligns the shape of covariate distributions (not just their means).

In [14]:
print(sample_with_target.covars().kld().T)
print(sample_with_target.covars().emd().T)
print(sample_with_target.covars().cvmd().T)
print(sample_with_target.covars().ks().T)
source         self
gender     0.079889
age_group  0.277138
income     0.114895
mean(kld)  0.157307
source         self
gender     0.188900
age_group  0.743700
income     6.440306
mean(emd)  2.457635
source          self
gender      0.012658
age_group   0.061326
income      0.029834
mean(cvmd)  0.034606
source         self
gender     0.187100
age_group  0.296500
income     0.246400
mean(ks)   0.243333

Visualizing the unadjusted comparison¶

Before we adjust the sample, let's visualize how the sample compares to the target:

In [15]:
sample_with_target.covars().plot()

Adjusting Sample to Population¶

Next, we adjust the sample to the target. The default method to be used is 'ipw' (which uses inverse probability/propensity weights, after running logistic regression with lasso regularization).

In [16]:
# Using ipw to fit survey weights
adjusted = sample_with_target.adjust()
INFO (2026-10-10 13:50:05,341) [ipw/ipw (line 734)]: Starting ipw function
INFO (2026-10-10 13:50:05,346) [adjustment/apply_transformations (line 438)]: Adding the variables: []
INFO (2026-10-10 13:50:05,347) [adjustment/apply_transformations (line 439)]: Transforming the variables: ['gender', 'age_group', 'income']
INFO (2026-10-10 13:50:05,356) [adjustment/apply_transformations (line 475)]: Final variables in output: ['gender', 'age_group', 'income']
INFO (2026-10-10 13:50:05,459) [ipw/ipw (line 811)]: Building model matrix
INFO (2026-10-10 13:50:05,460) [ipw/ipw (line 812)]: The formula used to build the model matrix: ['income + gender + age_group + _is_na_gender']
INFO (2026-10-10 13:50:05,460) [ipw/ipw (line 813)]: The number of columns in the model matrix: 16
INFO (2026-10-10 13:50:05,461) [ipw/ipw (line 814)]: The number of rows in the model matrix: 11000
INFO (2026-10-10 13:50:23,334) [ipw/ipw (line 1016)]: Done with sklearn
INFO (2026-10-10 13:50:23,335) [ipw/ipw (line 1018)]: max_de: None
INFO (2026-10-10 13:50:23,335) [ipw/ipw (line 1040)]: Starting model selection
INFO (2026-10-10 13:50:23,338) [ipw/ipw (line 1096)]: Chosen lambda: 0.041158338186664825
INFO (2026-10-10 13:50:23,338) [ipw/ipw (line 1113)]: Proportion null deviance explained 0.172637976731584
In [17]:
print(adjusted)
        Adjusted balance Sample object with target set using ipw
        1000 observations x 3 variables: gender,age_group,income,weight_pre_adjust,weight_adjusted_1
        id_column: id, weight_column: weight,
        outcome_columns: happiness
        
        adjustment details:
            method: ipw
            weight trimming mean ratio: 20
            design effect (Deff): 1.880
            effective sample size proportion (ESSP): 0.532
            effective sample size (ESS): 531.9
                
            target:
                 
	        balance Sample object
	        10000 observations x 3 variables: gender,age_group,income
	        id_column: id, weight_column: weight,
	        outcome_columns: happiness
	        
            3 common variables: gender,age_group,income
            

Compound / Sequential Adjustments¶

Sometimes a single adjustment pass is not enough. For instance, you may want to run IPW first to correct broad covariate imbalances, and then apply raking on specific variables for fine-tuning.

adjust() can be called multiple times. Each call uses the previously adjusted weights as design weights, so adjustments compound.

The original unadjusted baseline is always preserved, so asmd_improvement() shows the total improvement across all steps.

In [18]:
# Step 1: broad IPW correction across all covariates
adjusted_ipw = sample_with_target.adjust(
    method="ipw",
    max_de=2
)

# Step 2: fine-tune with raking on gender and age_group
adjusted_final = adjusted_ipw.adjust(
    method="rake",
    variables=["gender", "age_group"]
)

print("After IPW only:")
print(
    adjusted_ipw
    .covars()
    .asmd(aggregate_by_main_covar=True)
    .T
)

print("\nAfter IPW + rake on gender & age_group:")
print(
    adjusted_final
    .covars()
    .asmd(aggregate_by_main_covar=True)
    .T
)

print(
    f"\nTotal ASMD improvement (vs original): "
    f"{adjusted_final.covars().asmd_improvement():.2%}"
)
INFO (2026-10-10 13:50:23,374) [ipw/ipw (line 734)]: Starting ipw function
INFO (2026-10-10 13:50:23,379) [adjustment/apply_transformations (line 438)]: Adding the variables: []
INFO (2026-10-10 13:50:23,379) [adjustment/apply_transformations (line 439)]: Transforming the variables: ['gender', 'age_group', 'income']
INFO (2026-10-10 13:50:23,388) [adjustment/apply_transformations (line 475)]: Final variables in output: ['gender', 'age_group', 'income']
INFO (2026-10-10 13:50:23,508) [ipw/ipw (line 811)]: Building model matrix
INFO (2026-10-10 13:50:23,509) [ipw/ipw (line 812)]: The formula used to build the model matrix: ['income + gender + age_group + _is_na_gender']
INFO (2026-10-10 13:50:23,509) [ipw/ipw (line 813)]: The number of columns in the model matrix: 16
INFO (2026-10-10 13:50:23,510) [ipw/ipw (line 814)]: The number of rows in the model matrix: 11000
INFO (2026-10-10 13:50:25,108) [ipw/ipw (line 1016)]: Done with sklearn
INFO (2026-10-10 13:50:25,109) [ipw/ipw (line 1018)]: max_de: 2
INFO (2026-10-10 13:50:25,109) [ipw/choose_regularization (line 379)]: Starting choosing regularisation parameters
INFO (2026-10-10 13:50:32,112) [ipw/choose_regularization (line 465)]: Best regularisation: 
           s  s_index  trim  design_effect  asmd_improvement     asmd
9  0.009726      125   5.0       1.998665          0.711052  0.05646
INFO (2026-10-10 13:50:32,114) [ipw/ipw (line 1096)]: Chosen lambda: 0.009726392859944848
INFO (2026-10-10 13:50:32,115) [ipw/ipw (line 1113)]: Proportion null deviance explained 0.18189301966114857
INFO (2026-10-10 13:50:32,126) [adjustment/apply_transformations (line 438)]: Adding the variables: []
INFO (2026-10-10 13:50:32,126) [adjustment/apply_transformations (line 439)]: Transforming the variables: ['gender', 'age_group']
INFO (2026-10-10 13:50:32,131) [adjustment/apply_transformations (line 475)]: Final variables in output: ['gender', 'age_group']
INFO (2026-10-10 13:50:32,138) [rake/rake (line 627)]: Final covariates and levels that will be used in raking: {'age_group': ['18-24', '25-34', '35-44', '45+'], 'gender': ['Female', 'Male', '__NaN__']}.
After IPW only:
source          self  unadjusted  unadjusted - self
age_group   0.071234    0.232409           0.161175
gender      0.059885    0.253769           0.193884
income      0.189626    0.494217           0.304591
mean(asmd)  0.106915    0.326799           0.219883

After IPW + rake on gender & age_group:
source          self  unadjusted  unadjusted - self
age_group   0.000131    0.232409           0.232278
gender      0.000000    0.253769           0.253769
income      0.214527    0.494217           0.279690
mean(asmd)  0.071553    0.326799           0.255246

Total ASMD improvement (vs original): 78.10%

Evaluation of the Results¶

We can get a basic summary of the results:

In [19]:
print(adjusted.summary())
Adjustment details:
    method: ipw
    weight trimming mean ratio: 20
Covariate diagnostics:
    Covar ASMD reduction: 63.4%
    Covar ASMD (7 variables): 0.327 -> 0.120
    Covar mean KLD reduction: 92.3%
    Covar mean KLD (3 variables): 0.157 -> 0.012
Weight diagnostics:
    design effect (Deff): 1.880
    effective sample size proportion (ESSP): 0.532
    effective sample size (ESS): 531.9
Outcome weighted means:
            happiness
source               
self           53.295
target         56.278
unadjusted     48.559
Model performance: Model proportion deviance explained: 0.173
In [20]:
print(adjusted.covars().mean().T)
source                      self     target  unadjusted
_is_na_gender[T.True]   0.086776   0.089800    0.088000
age_group[T.25-34]      0.307355   0.297400    0.300000
age_group[T.35-44]      0.273609   0.299200    0.156000
age_group[T.45+]        0.137581   0.206300    0.053000
gender[Female]          0.406337   0.455100    0.268000
gender[Male]            0.506887   0.455100    0.644000
gender[_NA]             0.086776   0.089800    0.088000
income                 10.060068  12.737608    6.297302

We see an improvement in the average ASMD. We can look at detailed list of ASMD values per variables using the following call.

In [21]:
print(adjusted.covars().asmd().T)
source                  self  unadjusted  unadjusted - self
age_group[T.25-34]  0.021777    0.005688          -0.016090
age_group[T.35-44]  0.055884    0.312711           0.256827
age_group[T.45+]    0.169816    0.378828           0.209013
gender[Female]      0.097916    0.375699           0.277783
gender[Male]        0.103989    0.379314           0.275324
gender[_NA]         0.010578    0.006296          -0.004282
income              0.205469    0.494217           0.288748
mean(asmd)          0.119597    0.326799           0.207202
In [22]:
print(adjusted.covars().kld().T)
source         self  unadjusted  unadjusted - self
gender     0.005603    0.079889           0.074286
age_group  0.030191    0.277138           0.246947
income     0.000768    0.114895           0.114127
mean(kld)  0.012187    0.157307           0.145120

It's easier to learn about the biases by just running .covars().plot() on our adjusted object.

In [23]:
adjusted.covars().plot()  # you could change sizes using something like .plot(width = 1500, height = 700)

Love plot — covariate-balance scatter¶

Another canonical adjustment-diagnostic visual: a "Love plot" (after Thomas Love) shows the per-covariate ASMD before-vs-after weighting on a single sorted scatter, with a +0.1 reference line for the conventional "balance achieved" cutoff (R analog: cobalt::love.plot).

balance exposes this in v0.21+ via BalanceDFCovars.love_plot(). When called on an adjusted Sample (with linked unadjusted view), it draws both before-weighted and after-weighted ASMD; pre-adjust, it falls back to a single-series scatter of the current ASMD.

By default rows are sorted by signed after - before (order_by="diff"), so covariates whose imbalance got worse after weighting float to the top and the most-improved covariates sink to the bottom — making any regressions easy to spot at a glance.

In [24]:
adjusted.covars().love_plot()