Skip to content

Commit ef82ca8

Browse files
neuralsorcerermeta-codesync[bot]
authored andcommitted
Add formula support to covars() KLD diagnostics (#376)
Summary: - Closes #326 Pull Request resolved: #376 Differential Revision: D98613915 Pulled By: talgalili fbshipit-source-id: 8a8c042f317cee6c711842acaa0b362caa1f35ef
1 parent 4b063e2 commit ef82ca8

4 files changed

Lines changed: 561 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# 0.19.0 (2026-03-25)
1+
# 0.19.0 (Unreleased - TBD)
22

33
## Breaking Changes
44

@@ -16,6 +16,17 @@
1616
- **Removed `Sample.outcome_variance_ratio()`** — use `sample.outcomes().outcome_variance_ratio()` instead.
1717
Deprecated since 0.18.0.
1818

19+
## New Features
20+
21+
- **Added formula support to `Sample.covars()` for downstream diagnostics**
22+
- `Sample.covars()` now accepts a `formula` argument and stores it on the
23+
returned `BalanceDFCovars` object.
24+
- `BalanceDFCovars.kld()` now honors formula-driven model matrices (including
25+
interactions such as `"age_group * gender"`) when a formula is provided via
26+
`covars(formula=...)`.
27+
- Formula settings are now propagated to linked covariate views (`target`,
28+
`unadjusted`) so comparative diagnostics run on consistent design matrices.
29+
1930
# 0.18.0 (2026-03-24)
2031

2132
## New Features

balance/balancedf_class.py

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
import logging
11+
import re
1112
from typing import Any, Callable, Dict, Literal, Tuple
1213

1314
import numpy as np
@@ -285,14 +286,23 @@ def _BalanceDF_child_from_linked_samples(
285286
| "BalanceDFOutcomes"
286287
| None,
287288
] = {"self": self}
289+
linked_child_kwargs = self._linked_child_kwargs()
288290
d.update(
289291
{
290-
k: getattr(v, BalanceDF_child_method)()
292+
k: getattr(v, BalanceDF_child_method)(**linked_child_kwargs)
291293
for k, v in self._sample._links.items()
292294
}
293295
)
294296
return d
295297

298+
def _linked_child_kwargs(self: "BalanceDF") -> dict[str, Any]:
299+
"""Keyword arguments used when creating linked BalanceDF children.
300+
301+
Subclasses can override this to preserve construction options across linked
302+
samples (for example, formula settings for covariates).
303+
"""
304+
return {}
305+
296306
def _call_on_linked(
297307
self: "BalanceDF",
298308
method: str,
@@ -1276,8 +1286,18 @@ def _kld_BalanceDF(
12761286
) -> pd.Series:
12771287
"""Run KLD on two BalanceDF objects.
12781288
1279-
Prepares the BalanceDF objects by using their raw df (with NA indicators), and
1280-
then passes the df and weights from the two objects into :func:`weighted_comparisons_stats.kld`.
1289+
By default, this prepares the BalanceDF objects by using their raw df
1290+
(with NA indicators), and then passes the df and weights from the two
1291+
objects into :func:`weighted_comparisons_stats.kld`.
1292+
1293+
If either BalanceDF provides a formula for KLD comparisons (currently
1294+
:class:`BalanceDFCovars` with a stored formula), this method builds a
1295+
*shared* model matrix from the combined sample+target data using that
1296+
single effective formula and compares those aligned matrices instead of
1297+
raw covariates.
1298+
1299+
If both objects provide formulas and they differ, a ``ValueError`` is
1300+
raised to prevent comparing mismatched design matrices.
12811301
12821302
Args:
12831303
sample_BalanceDF (BalanceDF): Object
@@ -1287,6 +1307,57 @@ def _kld_BalanceDF(
12871307
Returns:
12881308
pd.Series: See :func:`weighted_comparisons_stats.kld`.
12891309
"""
1310+
BalanceDF._check_if_not_BalanceDF(sample_BalanceDF, "sample_BalanceDF")
1311+
BalanceDF._check_if_not_BalanceDF(target_BalanceDF, "target_BalanceDF")
1312+
1313+
sample_formula = sample_BalanceDF._kld_formula()
1314+
target_formula = target_BalanceDF._kld_formula()
1315+
1316+
if sample_formula is not None and target_formula is not None:
1317+
normalized_sample_formula = BalanceDF._normalize_formula_for_comparison(
1318+
sample_formula
1319+
)
1320+
normalized_target_formula = BalanceDF._normalize_formula_for_comparison(
1321+
target_formula
1322+
)
1323+
if normalized_sample_formula != normalized_target_formula:
1324+
raise ValueError(
1325+
"KLD formula mismatch between sample and target. "
1326+
f"Got sample formula {sample_formula!r} and target formula {target_formula!r}. "
1327+
"Use a single shared formula for both."
1328+
)
1329+
1330+
effective_formula = (
1331+
sample_formula if sample_formula is not None else target_formula
1332+
)
1333+
use_model_matrix = effective_formula is not None
1334+
1335+
if use_model_matrix:
1336+
mm = balance_util.model_matrix(
1337+
sample_BalanceDF.df,
1338+
target_BalanceDF.df,
1339+
add_na=True,
1340+
return_type="two",
1341+
formula=effective_formula,
1342+
)
1343+
sample_weights = (
1344+
sample_BalanceDF._weights.values
1345+
if sample_BalanceDF._weights is not None
1346+
else None
1347+
)
1348+
target_weights = (
1349+
target_BalanceDF._weights.values
1350+
if target_BalanceDF._weights is not None
1351+
else None
1352+
)
1353+
return weighted_comparisons_stats.kld(
1354+
_assert_type(mm["sample"], pd.DataFrame),
1355+
_assert_type(mm["target"], pd.DataFrame),
1356+
sample_weights,
1357+
target_weights,
1358+
aggregate_by_main_covar=aggregate_by_main_covar,
1359+
)
1360+
12901361
return BalanceDF._apply_comparison_stat_to_BalanceDF(
12911362
weighted_comparisons_stats.kld,
12921363
sample_BalanceDF,
@@ -1295,6 +1366,23 @@ def _kld_BalanceDF(
12951366
use_model_matrix=False,
12961367
)
12971368

1369+
def _kld_formula(self: "BalanceDF") -> str | list[str] | None:
1370+
"""Formula to use for KLD comparison matrices, if applicable."""
1371+
return None
1372+
1373+
@staticmethod
1374+
def _normalize_formula_for_comparison(
1375+
formula: str | list[str],
1376+
) -> tuple[str, ...]:
1377+
"""Normalize formulas for robust equality checks.
1378+
1379+
Insignificant whitespace is removed so equivalent formulas such as
1380+
``\"a*b\"`` and ``\"a * b\"`` compare equal.
1381+
"""
1382+
if isinstance(formula, str):
1383+
formula = [formula]
1384+
return tuple(re.sub(r"\s+", "", f) for f in formula)
1385+
12981386
@staticmethod
12991387
def _emd_BalanceDF(
13001388
sample_BalanceDF: "BalanceDF",
@@ -2570,7 +2658,11 @@ def outcome_variance_ratio(self: "BalanceDFOutcomes") -> pd.Series:
25702658

25712659

25722660
class BalanceDFCovars(BalanceDF):
2573-
def __init__(self: "BalanceDFCovars", sample: Sample) -> None:
2661+
def __init__(
2662+
self: "BalanceDFCovars",
2663+
sample: Sample,
2664+
formula: str | list[str] | None = None,
2665+
) -> None:
25742666
"""A factory function to create BalanceDFCovars
25752667
25762668
This is used through :func:`Sample.covars`.
@@ -2580,23 +2672,47 @@ def __init__(self: "BalanceDFCovars", sample: Sample) -> None:
25802672
Args:
25812673
self (BalanceDFCovars): Object that is initiated.
25822674
sample (Sample): Object
2675+
formula (str | list[str] | None, optional): Optional formula to use
2676+
as the default when constructing model matrices for this object.
25832677
"""
25842678
super().__init__(sample._covar_columns(), sample, name="covars")
2679+
self._formula: str | list[str] | None = formula
25852680

2681+
def model_matrix(
2682+
self: "BalanceDFCovars", formula: str | list[str] | None = None
2683+
) -> pd.DataFrame:
2684+
"""Return a model matrix, defaulting to the formula provided at construction."""
2685+
effective_formula = self._formula if formula is None else formula
2686+
return super().model_matrix(formula=effective_formula)
2687+
2688+
def _linked_child_kwargs(self: "BalanceDFCovars") -> dict[str, Any]:
2689+
"""Propagate formula choice to linked covariate views."""
2690+
if self._formula is None:
2691+
return {}
2692+
return {"formula": self._formula}
2693+
2694+
def _kld_formula(self: "BalanceDFCovars") -> str | list[str] | None:
2695+
"""Formula to use for KLD when comparing covariates."""
2696+
return self._formula
2697+
2698+
@classmethod
25862699
def from_frame(
2587-
self: "BalanceDFCovars",
2700+
cls: type["BalanceDFCovars"],
25882701
df: pd.DataFrame,
25892702
weights: pd.Series | None = None,
2703+
formula: str | list[str] | None = None,
25902704
) -> "BalanceDFCovars":
25912705
"""A factory function to create a BalanceDFCovars from a df.
25922706
25932707
Although generally the main way the object is created is through the __init__ method.
25942708
This method is useful when you need to create a BalanceDFCovars object directly from a DataFrame.
25952709
25962710
Args:
2597-
self (BalanceDFCovars): Object
2711+
cls (type[BalanceDFCovars]): Class object.
25982712
df (pd.DataFrame): A df.
25992713
weights (Optional[pd.Series], optional): _description_. Defaults to None.
2714+
formula (str | list[str] | None, optional): Optional formula to set on
2715+
the returned ``BalanceDFCovars`` object.
26002716
26012717
Returns:
26022718
BalanceDFCovars: Object.
@@ -2618,7 +2734,7 @@ def from_frame(
26182734
if weights is not None:
26192735
concat_list.append(weights)
26202736
df = pd.concat(concat_list, axis=1)
2621-
return Sample.from_frame(df, id_column="id").covars()
2737+
return Sample.from_frame(df, id_column="id").covars(formula=formula)
26222738

26232739

26242740
class BalanceDFWeights(BalanceDF):

balance/sample_class.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,7 @@ def weights(
766766
return BalanceDFWeights(self)
767767

768768
def covars(
769-
self: "Sample",
769+
self: "Sample", formula: str | list[str] | None = None
770770
) -> (
771771
Any
772772
): # -> "Optional[Type[BalanceDFCovars]]" (not imported due to circular dependency)
@@ -776,6 +776,12 @@ def covars(
776776
777777
Args:
778778
self (Sample): Sample object.
779+
formula (str | list[str] | None, optional): Optional formula string
780+
(or list of formulas) used when creating model matrices from the
781+
returned ``BalanceDFCovars`` object. If provided, methods that
782+
rely on model matrices (or distribution-comparison methods that
783+
opt into model-matrix behavior for formula-based covariates) will
784+
use this formula. Defaults to None.
779785
780786
Returns:
781787
BalanceDFCovars
@@ -805,7 +811,7 @@ def covars(
805811
# NOTE: must import here so to avoid circular dependency
806812
from balance.balancedf_class import BalanceDFCovars
807813

808-
return BalanceDFCovars(self)
814+
return BalanceDFCovars(self, formula=formula)
809815

810816
def ignored_columns(self: "Sample") -> pd.DataFrame | None:
811817
"""Return columns marked as ignored on the sample.

0 commit comments

Comments
 (0)