88from __future__ import annotations
99
1010import logging
11+ import re
1112from typing import Any , Callable , Dict , Literal , Tuple
1213
1314import 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
25722660class 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
26242740class BalanceDFWeights (BalanceDF ):
0 commit comments