balance: outcome modelling end-to-end (the g-computation estimate μ̂_OM)¶

Once a sample has been reweighted to a target, balance can estimate the target-population mean of an outcome in more than one way. This tutorial walks the outcome-model (g-computation) estimator μ̂_OM end-to-end and compares it to the inverse-propensity-weighted estimate μ̂_IPW, on the same simulated data as the Quickstart.

  • μ̂_IPW — inverse-propensity (Hájek) weighted mean (outcomes().mean()): the weighted average of the responders' observed outcome using the adjustment weights. Consistent when the weighting (propensity) model is correct.
  • μ̂_OM — outcome-model / g-computation estimate (outcomes_hat().mean()): fit a learner ĝ(X) ≈ E[Y|X] on the responders, apply it to the target covariates, and average the predicted outcomes with the target weights. Consistent when the outcome model is correct. Added in v0.23.

The two estimators use the covariates differently, so comparing them is a useful robustness check: agreement is reassuring, disagreement points at a misspecified weighting or outcome model. (A doubly-robust / AIPW estimator that combines both is available as bf.aipw() — point estimate only; variance/CI and cross-fitting remain deferred.)

Load the data¶

We use the same simulated toy dataset as the Quickstart: a biased sample and the target population we want to estimate. The outcome is happiness; the target also carries happiness here only so we can compare our estimates against the ground truth.

In [1]:
import warnings

# Quiet Python warnings so the tutorial reads cleanly; in real use you may
# want to keep them (they flag e.g. guessed id/weight columns). INFO logs
# are left on so you can watch what balance does.
warnings.filterwarnings("ignore")

from balance import load_data, Sample

target_df, sample_df = load_data()
sample_df.head()
INFO (2026-07-21 12:51:14,824) [__init__/<module> (line 77)]: Using balance version 0.22.0.x
INFO (2026-07-21 12:51:14,824) [__init__/<module> (line 82)]: 
balance (Version 0.22.0.x) 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()

Out[1]:
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

Build a Sample, link the target, and adjust¶

We load the sample and target into Sample objects (declaring happiness as the outcome), link them, and run the default IPW adjustment — exactly the Quickstart workflow. The outcome model itself does not require the weights, but we adjust here so we can compare μ̂_OM against μ̂_IPW.

In [2]:
sample = Sample.from_frame(sample_df, outcome_columns=["happiness"])
target = Sample.from_frame(target_df, outcome_columns=["happiness"])

bf = sample.set_target(target).adjust(method="ipw")
WARNING (2026-07-21 12:51:14,852) [input_validation/guess_id_column (line 336)]: Guessed id column name id for the data
WARNING (2026-07-21 12:51:14,864) [sample_frame/from_frame (line 377)]: No weights passed. Adding a 'weight' column and setting all values to 1
WARNING (2026-07-21 12:51:14,865) [input_validation/guess_id_column (line 336)]: Guessed id column name id for the data
WARNING (2026-07-21 12:51:14,880) [sample_frame/from_frame (line 377)]: No weights passed. Adding a 'weight' column and setting all values to 1
INFO (2026-07-21 12:51:14,889) [ipw/ipw (line 735)]: Starting ipw function
INFO (2026-07-21 12:51:14,893) [adjustment/apply_transformations (line 435)]: Adding the variables: []
INFO (2026-07-21 12:51:14,893) [adjustment/apply_transformations (line 436)]: Transforming the variables: ['gender', 'age_group', 'income']
INFO (2026-07-21 12:51:14,903) [adjustment/apply_transformations (line 472)]: Final variables in output: ['gender', 'age_group', 'income']
INFO (2026-07-21 12:51:15,019) [ipw/ipw (line 813)]: Building model matrix
INFO (2026-07-21 12:51:15,020) [ipw/ipw (line 814)]: The formula used to build the model matrix: ['income + gender + age_group + _is_na_gender']
INFO (2026-07-21 12:51:15,020) [ipw/ipw (line 815)]: The number of columns in the model matrix: 16
INFO (2026-07-21 12:51:15,021) [ipw/ipw (line 816)]: The number of rows in the model matrix: 11000
INFO (2026-07-21 12:51:32,642) [ipw/ipw (line 1012)]: Done with sklearn
INFO (2026-07-21 12:51:32,643) [ipw/ipw (line 1014)]: max_de: None
INFO (2026-07-21 12:51:32,644) [ipw/ipw (line 1036)]: Starting model selection
INFO (2026-07-21 12:51:32,648) [ipw/ipw (line 1092)]: Chosen lambda: 0.041158338186664825
INFO (2026-07-21 12:51:32,649) [ipw/ipw (line 1109)]: Proportion null deviance explained 0.172637976731583

μ̂_IPW — the inverse-propensity weighted estimate¶

outcomes().mean() averages the observed outcome across sources:

  • the self row is the reweighted sample — this is μ̂_IPW, the IPW estimate of the target mean;
  • the target row is the target's own observed happiness (the ground truth, available in this simulation);
  • the unadjusted row is the raw (unweighted) sample mean.

The unadjusted sample badly under-estimates the target; reweighting pulls the self estimate toward the truth.

In [3]:
bf.outcomes().mean().round(3)
Out[3]:
happiness
source
self 53.295
target 56.278
unadjusted 48.559

μ̂_OM — the outcome-model (g-computation) estimate¶

Now the outcome model. fit_outcome_model() fits ĝ(X) ≈ E[Y|X] on the responders (the default model="auto" is a gradient-boosted tree that handles the categorical covariates natively). predict_outcomes(on="both") then scores both the responders (in-sample ŷ) and the target, and populates the outcomes_hat (ŷ) columns.

In outcomes_hat().mean() the target row is μ̂_OM: the weighted mean of the predicted outcome over the target. (The self row is the in-sample ŷ on the responders — a fit-quality check, not the population estimate.)

In [4]:
bf.fit_outcome_model(model="auto")
bf.predict_outcomes(on="both")

bf.outcomes_hat().mean().round(3)
INFO (2026-07-21 12:51:32,755) [outcome_model/fit_outcome_model (line 680)]: outcome_model: outcome 'happiness' (continuous) -> HistGradientBoostingRegressor [auto]
Out[4]:
happiness_hat
source
self 53.242
target 54.818

An honest confidence interval for μ̂_OM¶

The analytic CI treats the predictions ŷ as fixed, so it under-covers μ̂_OM. mean_with_ci(ci_method="bootstrap") gives an honest interval: it resamples the responders, refits ĝ*, predicts on the fixed target, and re-averages — a percentile CI over the replicates. It is deterministic given random_seed. (The default model="auto" is seeded, so μ̂_OM itself reproduces run-to-run.)

In [5]:
bf.outcomes_hat().mean_with_ci(ci_method="bootstrap", n_bootstrap=200, random_seed=2020)
INFO (2026-07-21 12:51:33,041) [outcome_model/fit_outcome_model (line 680)]: outcome_model: outcome 'happiness' (continuous) -> HistGradientBoostingRegressor [model['happiness']]
INFO (2026-07-21 12:51:33,271) [outcome_model/bootstrap_outcome_estimate (line 1066)]: bootstrap_outcome_estimate: fitting 200 bootstrap replicate(s) (per-replicate logs suppressed).
Out[5]:
target target_ci
happiness_hat 54.818 (53.893, 55.857)

outcomes_hat().summary() reports the estimator and scopes any doubly-robust claim to the fit weights — it never prints a blanket "doubly robust" (a plain g-computation for the non-linear default; doubly robust only for a weighted linear-with-intercept fit).

Compare its analytic target CI to the bootstrap interval above: the analytic one is noticeably narrower because it treats ŷ as fixed — exactly the under-coverage the bootstrap corrects.

In [6]:
print(bf.outcomes_hat().summary())
1 predicted outcome(s): ['happiness_hat']
Estimator: g-computation (model='auto') (not doubly robust)
Mean predicted outcomes (analytic 95% CIs — these treat the predictions as fixed and under-cover the outcome-model estimate; use mean_with_ci(ci_method='bootstrap') for an honest interval):
source           self  target           self_ci         target_ci
happiness_hat  53.242  54.818  (52.301, 54.184)  (54.612, 55.023)

μ̂_DR — the doubly-robust (AIPW) estimate¶

bf.aipw() combines the two estimators: it takes the outcome-model prediction on the target and augments it with the IPW-weighted residual on the responders,

μ̂_DR = wmean(ĝ(X_T), w_T) + wmean(Y − ĝ(X_S), w),

so it is consistent if either the outcome model or the weighting model is correct — a safety net when you are not sure which to trust. It reuses the model fit above and the IPW weights from adjust().

Point estimate only. An honest AIPW interval must jointly capture the weighting- and outcome-model uncertainty (ideally with a cross-fit ĝ); that is on the roadmap but not yet available, so aipw() returns the point estimate alone.

In [7]:
bf.aipw().round(3)
Out[7]:
happiness    54.871
dtype: float64

IPW, outcome model, and AIPW — side by side¶

All three estimators substantially reduce the bias of the raw sample mean and land close to the ground truth — and close to each other, which is the reassuring case. A large gap between μ̂_IPW, μ̂_OM, and μ̂_DR would instead flag a misspecified weighting or outcome model.

In [8]:
import pandas as pd

ipw = bf.outcomes().mean()
om = bf.outcomes_hat().mean()
pd.DataFrame(
    {
        "estimate_of_target_happiness": {
            "unadjusted (raw sample mean)": ipw.loc["unadjusted", "happiness"],
            "mu_hat_IPW (reweighted sample)": ipw.loc["self", "happiness"],
            "mu_hat_OM (outcome model)": om.loc["target", "happiness_hat"],
            "mu_hat_DR (AIPW, doubly robust)": bf.aipw()["happiness"],
            "ground truth (target)": ipw.loc["target", "happiness"],
        }
    }
).round(2)
Out[8]:
estimate_of_target_happiness
unadjusted (raw sample mean) 48.56
mu_hat_IPW (reweighted sample) 53.30
mu_hat_OM (outcome model) 54.82
mu_hat_DR (AIPW, doubly robust) 54.87
ground truth (target) 56.28

Which estimate should I use?

Estimator Consistent when… Reach for it when
μ̂_IPW — outcomes().mean() the weighting model is right you trust the propensity / weighting model
μ̂_OM — outcomes_hat().mean() the outcome model is right you trust ĝ(X) ≈ E[Y\|X]
μ̂_DR — aipw() either model is right (doubly robust) you're unsure which to trust

When they agree (as they do here) that is reassuring; a large gap flags a misspecified weighting or outcome model.

Train / holdout transfer¶

You can fit the outcome model on one frame (a train split) and apply it to a different frame (a holdout / scoring split) with the same covariate schema — mirroring how set_fitted_model transfers a fitted IPW model. set_fitted_outcome_model copies the already-fitted model onto the holdout without re-fitting (the fitted learner is shared by identity), so predicting on the holdout target gives μ̂_OM computed with the train model.

In [9]:
half = len(sample_df) // 2
train_bf = (
    Sample.from_frame(sample_df.iloc[:half], outcome_columns=["happiness"])
    .set_target(target)
    .adjust(method="ipw")
)
train_bf.fit_outcome_model(model="auto")

holdout_bf = Sample.from_frame(
    sample_df.iloc[half:], outcome_columns=["happiness"]
).set_target(target)

# Graft the train model onto the holdout (no re-fitting), then score the holdout.
scored = holdout_bf.set_fitted_outcome_model(train_bf, inplace=False)
scored.predict_outcomes(on="both")
scored.outcomes_hat().mean().round(3)  # target row = mu_hat_OM on the holdout target
WARNING (2026-07-21 12:52:19,390) [input_validation/guess_id_column (line 336)]: Guessed id column name id for the data
WARNING (2026-07-21 12:52:19,401) [sample_frame/from_frame (line 377)]: No weights passed. Adding a 'weight' column and setting all values to 1
INFO (2026-07-21 12:52:19,408) [ipw/ipw (line 735)]: Starting ipw function
INFO (2026-07-21 12:52:19,413) [adjustment/apply_transformations (line 435)]: Adding the variables: []
INFO (2026-07-21 12:52:19,413) [adjustment/apply_transformations (line 436)]: Transforming the variables: ['gender', 'age_group', 'income']
INFO (2026-07-21 12:52:19,421) [adjustment/apply_transformations (line 472)]: Final variables in output: ['gender', 'age_group', 'income']
INFO (2026-07-21 12:52:19,531) [ipw/ipw (line 813)]: Building model matrix
INFO (2026-07-21 12:52:19,532) [ipw/ipw (line 814)]: The formula used to build the model matrix: ['income + gender + age_group + _is_na_gender']
INFO (2026-07-21 12:52:19,532) [ipw/ipw (line 815)]: The number of columns in the model matrix: 16
INFO (2026-07-21 12:52:19,533) [ipw/ipw (line 816)]: The number of rows in the model matrix: 10500
INFO (2026-07-21 12:52:37,248) [ipw/ipw (line 1012)]: Done with sklearn
INFO (2026-07-21 12:52:37,249) [ipw/ipw (line 1014)]: max_de: None
INFO (2026-07-21 12:52:37,249) [ipw/ipw (line 1036)]: Starting model selection
INFO (2026-07-21 12:52:37,252) [ipw/ipw (line 1092)]: Chosen lambda: 0.05431749824945828
INFO (2026-07-21 12:52:37,253) [ipw/ipw (line 1109)]: Proportion null deviance explained 0.1626470503637164
INFO (2026-07-21 12:52:37,260) [outcome_model/fit_outcome_model (line 680)]: outcome_model: outcome 'happiness' (continuous) -> HistGradientBoostingRegressor [auto]
WARNING (2026-07-21 12:52:37,439) [input_validation/guess_id_column (line 336)]: Guessed id column name id for the data
WARNING (2026-07-21 12:52:37,450) [sample_frame/from_frame (line 377)]: No weights passed. Adding a 'weight' column and setting all values to 1
WARNING (2026-07-21 12:52:37,457) [model_matrix/build_design_matrix (line 914)]: build_design_matrix: holdout data is missing 1 fit-time column(s); zero-filling unseen columns: ['_is_na_gender']
Out[9]:
happiness_hat
source
self 48.475
target 55.100

Choosing the model (model=) and its inputs (variables=)¶

The estimator is chosen with model= and the covariate inputs with variables=. model= accepts "auto" (a HistGradientBoosting regressor/classifier picked by outcome type), a single sklearn estimator (cloned per outcome), a {"_discrete": clf, "_continuous": reg} type map, or a {outcome_column: estimator} column map. variables= restricts the model inputs X to a subset of the covariates (default: all of them).

In [10]:
from sklearn.linear_model import LinearRegression, LogisticRegression

# a single sklearn estimator (cloned per outcome), restricted to a covariate subset:
bf.fit_outcome_model(model=LinearRegression(), variables=["age_group", "income"])
bf.outcome_model["X_matrix_columns"][:4]
INFO (2026-07-21 12:52:37,546) [outcome_model/fit_outcome_model (line 680)]: outcome_model: outcome 'happiness' (continuous) -> LinearRegression [single LinearRegression]
Out[10]:
['age_group[18-24]', 'age_group[25-34]', 'age_group[35-44]', 'age_group[45+]']

Weighted fit and the doubly-robust special case¶

The outcome model is unweighted by default (weighted=False): it estimates E[Y|X] and is usually best left unbiased by the design weights. Passing weighted=True fits with the frame's active weights; for a linear model with an intercept, a weighted-least-squares fit makes the estimate doubly robust with respect to those weights, which summary() reports explicitly.

In [11]:
bf.fit_outcome_model(model=LinearRegression(), weighted=True)
bf.predict_outcomes(on="both")
print(bf.outcomes_hat().summary())
INFO (2026-07-21 12:52:37,570) [outcome_model/fit_outcome_model (line 680)]: outcome_model: outcome 'happiness' (continuous) -> LinearRegression [single LinearRegression]
WARNING (2026-07-21 12:52:37,595) [model_matrix/_prepare_input_model_matrix (line 452)]: Dropping all rows with NAs
WARNING (2026-07-21 12:52:37,611) [model_matrix/_prepare_input_model_matrix (line 452)]: Dropping all rows with NAs
1 predicted outcome(s): ['happiness_hat']
Estimator: g-computation (model=LinearRegression()); doubly robust w.r.t. weights `weight` (linear + intercept fit by weighted least squares — the residuals are orthogonal to the design for those weights)
Mean predicted outcomes (analytic 95% CIs — these treat the predictions as fixed and under-cover the outcome-model estimate; use mean_with_ci(ci_method='bootstrap') for an honest interval):
source           self  target          self_ci         target_ci
happiness_hat  53.599  52.216  (52.57, 54.629)  (52.039, 52.394)

Binary outcomes: classification, calibration, and a per-type model map¶

A binary 0/1 outcome is modelled with a classifier and stored as the predicted probability P(Y=1) (so the estimate is the target prevalence). calibrate=True wraps the classifier in CalibratedClassifierCV. Below, a {"_discrete", "_continuous"} type map routes the binary happy outcome to logistic regression.

In [12]:
sample_bin = Sample.from_frame(
    sample_df.assign(
        happy=(sample_df["happiness"] > sample_df["happiness"].median()).astype(float)
    ).drop(columns=["happiness"]),
    outcome_columns=["happy"],
)
sample_bin.fit_outcome_model(
    model={"_discrete": LogisticRegression(max_iter=1000), "_continuous": LinearRegression()},
    calibrate=True,
)
sample_bin.outcome_model["prediction_kind"], sample_bin.outcome_model["calibrated"]
WARNING (2026-07-21 12:52:37,666) [input_validation/guess_id_column (line 336)]: Guessed id column name id for the data
WARNING (2026-07-21 12:52:37,677) [sample_frame/from_frame (line 377)]: No weights passed. Adding a 'weight' column and setting all values to 1
INFO (2026-07-21 12:52:37,680) [outcome_model/fit_outcome_model (line 680)]: outcome_model: outcome 'happy' (discrete) -> LogisticRegression [model['_discrete']]
Out[12]:
({'happy': 'proba'}, True)

Fit and predict in one call¶

fit_predict_outcomes(...) is the sklearn-style fit_predict: it fits the model and then writes the <outcome>_hat columns in a single call.

In [13]:
bf.fit_predict_outcomes(model="auto", on="both")
bf.outcomes_hat().mean().round(3)
INFO (2026-07-21 12:52:37,785) [outcome_model/fit_outcome_model (line 680)]: outcome_model: outcome 'happiness' (continuous) -> HistGradientBoostingRegressor [auto]
Out[13]:
happiness_hat
source
self 53.242
target 54.818

Under the hood: the pure DataFrame API¶

The frame methods above wrap pure functions in balance.outcome_models that operate on plain DataFrames: fit_outcome_model / predict_outcome, the reusable bootstrap_outcome_estimate engine, and learner_from_model (which reconstructs the per-outcome estimators). weighted_r2 (in stats_and_plots) is the weighted regression-fit metric behind the stored perf, and add_outcomes_hat_column attaches a <outcome>_hat column directly.

In [14]:
from balance.outcome_models import (
    fit_outcome_model,
    predict_outcome,
    learner_from_model,
    bootstrap_outcome_estimate,
)
from balance.stats_and_plots.weighted_stats import weighted_r2

covars_R, y_R = sample.covars().df, sample.outcomes().df
w_R = sample.weights().df.iloc[:, 0]

model = fit_outcome_model(covars_R, y_R, sample_weight=w_R, model="auto")  # pure fit -> dict
yhat_R = predict_outcome(model, covars_R)["happiness"]                     # pure replay
print("weighted R2:", round(weighted_r2(y_R["happiness"], yhat_R, w=w_R), 3))
print("reconstructed learners:", list(learner_from_model(model)))
print("bootstrap:", bootstrap_outcome_estimate(
    covars_R, y_R, w_R, target.covars().df, target.weights().df.iloc[:, 0],
    fit_kwargs={"model": LinearRegression()}, n_bootstrap=50, random_seed=2020,  # fewer replicates just to demo the API
)["happiness"])

# attach a predicted-outcome column directly (the low-level data-model accessor):
sample.add_outcomes_hat_column("happiness_hat", pd.Series(yhat_R, index=covars_R.index))
sample.outcomes_hat_columns
INFO (2026-07-21 12:52:38,093) [outcome_model/fit_outcome_model (line 680)]: outcome_model: outcome 'happiness' (continuous) -> HistGradientBoostingRegressor [auto]
INFO (2026-07-21 12:52:38,325) [outcome_model/fit_outcome_model (line 680)]: outcome_model: outcome 'happiness' (continuous) -> LinearRegression [single LinearRegression]
WARNING (2026-07-21 12:52:38,353) [model_matrix/_prepare_input_model_matrix (line 452)]: Dropping all rows with NAs
INFO (2026-07-21 12:52:38,368) [outcome_model/bootstrap_outcome_estimate (line 1066)]: bootstrap_outcome_estimate: fitting 50 bootstrap replicate(s) (per-replicate logs suppressed).
weighted R2: 0.652
reconstructed learners: ['happiness']
bootstrap: {'estimate': 52.4895116622256, 'ci_low': 51.4597169599102, 'ci_high': 53.555405431160345}
Out[14]:
['happiness_hat']

Where to go next¶

  • The Quickstart covers the reweighting workflow (diagnostics, adjustment methods, weights) that produces the μ̂_IPW estimate above.
  • outcomes().weights_impact_on_outcome_ss() is a diagnostic (not a third estimator): it reports how much the weights move the outcome mean, with a significance test.
  • The outcome-model design doc covers the estimator theory and the AIPW roadmap.
In [15]:
import session_info

session_info.show(html=False, dependencies=True)
-----
balance             0.22.0.x
pandas              3.0.3
session_info        v1.0.1
sklearn             1.9.0
-----
PIL                         12.3.0
ada92cb5d92a588d1b93__mypyc NA
anyio                       NA
arrow                       1.4.0
asttokens                   NA
attr                        26.1.0
attrs                       26.1.0
babel                       2.18.0
certifi                     2026.06.17
charset_normalizer          3.4.9
comm                        0.2.3
cycler                      0.12.1
cython_runtime              NA
dateutil                    2.9.0.post0
debugpy                     1.8.21
decorator                   5.3.1
defusedxml                  0.7.1
executing                   2.2.1
fastjsonschema              NA
fontTools                   4.63.0
fqdn                        NA
idna                        3.18
ipykernel                   7.3.0
isoduration                 NA
jedi                        0.20.0
jinja2                      3.1.6
joblib                      1.5.3
json5                       0.15.0
jsonpointer                 3.1.1
jsonschema                  4.26.0
jsonschema_specifications   NA
jupyter_events              0.12.1
jupyter_server              2.20.0
jupyterlab_server           2.28.0
kiwisolver                  1.5.0
lark                        1.3.1
markupsafe                  3.0.3
matplotlib                  3.11.1
mpl_toolkits                NA
narwhals                    2.24.0
nbformat                    5.10.4
numpy                       2.5.1
packaging                   26.2
parso                       0.8.7
patsy                       1.0.2
platformdirs                4.10.1
plotly                      6.9.0
prometheus_client           NA
prompt_toolkit              3.0.52
psutil                      7.2.2
pure_eval                   0.2.3
pydev_ipython               NA
pydevconsole                NA
pydevd                      3.4.1
pydevd_file_utils           NA
pydevd_plugins              NA
pydevd_tracing              NA
pygments                    2.20.0
pyparsing                   3.3.2
pythonjsonlogger            NA
referencing                 NA
requests                    2.34.2
rfc3339_validator           0.1.4
rfc3986_validator           0.1.1
rfc3987_syntax              NA
rpds                        NA
scipy                       1.18.0
seaborn                     0.13.2
send2trash                  NA
six                         1.17.0
sphinxcontrib               NA
stack_data                  0.6.3
statsmodels                 0.14.6
threadpoolctl               3.6.0
tornado                     6.5.7
traitlets                   5.15.1
typing_extensions           NA
uri_template                NA
urllib3                     2.7.0
wcwidth                     0.8.2
webcolors                   NA
websocket                   1.9.0
yaml                        6.0.3
zmq                         27.1.0
zoneinfo                    NA
-----
IPython             9.15.0
jupyter_client      8.9.1
jupyter_core        5.9.1
jupyterlab          4.6.2
notebook            7.6.0
-----
Python 3.12.13 (main, Jun 16 2026, 22:05:08) [GCC 13.3.0]
Linux-6.17.0-1020-azure-x86_64-with-glibc2.39
-----
Session information updated at 2026-07-21 12:52