Skip to content

Commit 3fb8834

Browse files
talgalilifacebook-github-bot
authored andcommitted
Extract rake predict_weights logic into weighting_methods/rake.py
Summary: Moves the bulk of `BalanceFrame._predict_weights_rake` (~210 lines) into a new `predict_weights_from_model` function in `balance/weighting_methods/rake.py`. The BalanceFrame method becomes a thin ~30-line wrapper. Motivation: balance_frame.py was already ~2,300 lines and rake-specific predict logic was awkwardly far from rake's fit code. Co-locating the lifecycle (fit + replay + transfer) inside `weighting_methods/rake.py` makes it easier to reason about rake's contract — anything that depends on the rake-specific model dict structure (m_fit, m_sample, categories, training_*_weights, etc.) now lives next to the function that produces that structure. This also establishes a template for the same extraction on cbps, poststratify, and ipw in subsequent diffs. What stays in `BalanceFrame._predict_weights_rake` (~30-line wrapper): resolve `self` vs `source`, validate that `data` has a target set, extract sample/target covariate DataFrames and design weights from the BalanceFrame, call into `predict_weights_from_model`, apply weight name and `_align_to_index` on the result. What moves to `weighting_methods/rake.py::predict_weights_from_model`: all metadata validation (required keys, types, transformations_origin/data-dependent guards), covariate column existence checks, stored-transformations replay, NA handling (drop / add_indicator), category-code mapping against stored fit-time categories, per-cell ratio compute (m_fit / m_sample at the scored cells only), unconditional transfer warning, joint-support validation, target-sum determination across in-place vs transfer × na_action variants, trim_weights, and na_action="drop" full-index restoration. Side benefit: `predict_weights_from_model` is callable directly from outside BalanceFrame — users holding a fitted rake model dict and plain DataFrames can replay/transfer weights without going through the BalanceFrame class. No public API change: `BalanceFrame.predict_weights()` behavior is unchanged. Differential Revision: D103588867
1 parent 4cc7d8a commit 3fb8834

2 files changed

Lines changed: 295 additions & 231 deletions

File tree

balance/balance_frame.py

Lines changed: 9 additions & 230 deletions
Original file line numberDiff line numberDiff line change
@@ -2109,246 +2109,25 @@ def _predict_weights_rake(
21092109
model: dict[str, Any],
21102110
source: BalanceFrame | None = None,
21112111
) -> pd.Series:
2112-
required = (
2113-
"variables",
2114-
"variables_before_transformations",
2115-
"categories",
2116-
"m_fit",
2117-
"m_sample",
2118-
"na_action",
2119-
"transformations",
2120-
)
2121-
if source is None:
2122-
required = required + ("training_sample_weights", "training_target_weights")
2123-
missing = [key for key in required if key not in model]
2124-
if missing:
2125-
raise ValueError(
2126-
"Rake model is missing fit-time metadata "
2127-
f"({missing}) for predict_weights(). "
2128-
"Call BalanceFrame.fit(method='rake') or run rake(..., "
2129-
"store_fit_metadata=True)."
2130-
)
2131-
2132-
variables = model.get("variables")
2133-
input_variables = model.get("variables_before_transformations")
2134-
categories = model.get("categories")
2135-
m_fit = model.get("m_fit")
2136-
m_sample = model.get("m_sample")
2137-
transformations_origin = model.get("transformations_origin")
2138-
if (
2139-
not isinstance(variables, list)
2140-
or not isinstance(input_variables, list)
2141-
or not isinstance(categories, list)
2142-
):
2143-
raise ValueError("Rake model metadata is malformed for predict_weights().")
2144-
if not isinstance(m_fit, np.ndarray) or not isinstance(m_sample, np.ndarray):
2145-
raise ValueError("Rake model is missing stored contingency tables.")
2112+
from balance.weighting_methods.rake import predict_weights_from_model
21462113

21472114
bf = source if source is not None else self
21482115
if source is not None and bf._sf_target is None:
21492116
raise ValueError(
21502117
"data must have a target set for rake predict_weights(data=...)."
21512118
)
2152-
if source is not None and transformations_origin == "default":
2153-
raise ValueError(
2154-
"Rake predict_weights(data=...) is unsupported for models fitted "
2155-
"with transformations='default' because those transformations are "
2156-
"data-dependent and not replayable across new samples. Re-fit on "
2157-
"the scoring data or fit with explicit deterministic transformations."
2158-
)
2159-
if source is not None and isinstance(transformations_origin, dict):
2160-
# Best-effort guard: reject explicit dicts that directly
2161-
# reference balance's known data-dependent helpers
2162-
# (quantize, fct_lump). These recompute bins/levels from the
2163-
# scoring data, so stored cell ratios no longer line up with
2164-
# the transformed scoring cells and transfer would silently
2165-
# return incorrect weights.
2166-
#
2167-
# This guard does NOT catch indirect uses such as
2168-
# ``functools.partial(fct_lump, prop=0.1)``, top-level wrapper
2169-
# functions, or user-defined data-dependent transformations.
2170-
# The general invariant is: any callable whose output for a
2171-
# row depends on other rows in the input is unsafe to replay
2172-
# on a different sample. Users supplying such transformations
2173-
# are responsible for either (a) wrapping them as
2174-
# deterministic functions of stored fit-time parameters or
2175-
# (b) re-fitting rake on the scoring data.
2176-
from balance.utils.data_transformation import fct_lump, quantize
2177-
2178-
data_dependent_helpers = {quantize, fct_lump}
2179-
offenders = sorted(
2180-
{
2181-
getattr(fn, "__name__", repr(fn))
2182-
for fn in transformations_origin.values()
2183-
if fn in data_dependent_helpers
2184-
}
2185-
)
2186-
if offenders:
2187-
raise ValueError(
2188-
"Rake predict_weights(data=...) is unsupported for models "
2189-
f"fitted with data-dependent transformations ({', '.join(offenders)}). "
2190-
"These recompute bins/levels from the scoring data, so "
2191-
"stored cell ratios no longer line up with the transformed "
2192-
"cells. Re-fit on the scoring data or fit with deterministic "
2193-
"transformations."
2194-
)
2195-
sample_covars = bf._sf_sample.df_covars
2196-
target_covars = _assert_type(bf._sf_target).df_covars
2197-
for column in input_variables:
2198-
if (
2199-
column not in sample_covars.columns
2200-
or column not in target_covars.columns
2201-
):
2202-
raise ValueError(
2203-
"Rake predict_weights() cannot find required covariate "
2204-
f"'{column}' in both sample and target."
2205-
)
2206-
sample_df = sample_covars.loc[:, input_variables]
2207-
target_df = target_covars.loc[:, input_variables]
2208-
sample_weights_full = bf._sf_sample.df_weights.iloc[:, 0]
2209-
training_sample_weights = model.get("training_sample_weights")
2210-
sample_weights = sample_weights_full
2211-
na_action = cast(str, model.get("na_action", "add_indicator"))
22122119

2213-
sample_df, target_df = balance_adjustment.apply_transformations(
2214-
(sample_df, target_df), transformations=model.get("transformations")
2120+
target_frame = _assert_type(bf._sf_target)
2121+
predicted = predict_weights_from_model(
2122+
model=model,
2123+
sample_df=bf._sf_sample.df_covars,
2124+
sample_weights_full=bf._sf_sample.df_weights.iloc[:, 0],
2125+
target_df=target_frame.df_covars,
2126+
target_weights=target_frame.df_weights.iloc[:, 0],
2127+
is_transfer=source is not None,
22152128
)
2216-
for column in variables:
2217-
if column not in sample_df.columns:
2218-
raise ValueError(
2219-
"Rake transform output is missing stored variable "
2220-
f"'{column}' required for predict_weights()."
2221-
)
2222-
sample_df = sample_df.loc[:, variables]
2223-
target_df = target_df.loc[:, variables]
22242129

2225-
if source is None:
2226-
if isinstance(training_sample_weights, pd.Series):
2227-
if na_action == "drop":
2228-
sample_weights = training_sample_weights
2229-
elif training_sample_weights.index.equals(sample_weights_full.index):
2230-
sample_weights = training_sample_weights
2231-
else:
2232-
raise ValueError(
2233-
"Rake predict_weights() requires compatible fit-time sample design "
2234-
"weights for in-place replay. This can happen because "
2235-
"store_fit_metadata is missing/incompatible, or because you're "
2236-
"scoring a different sample; use predict_weights(data=...) "
2237-
"for different samples."
2238-
)
2239-
else:
2240-
raise ValueError(
2241-
"Rake predict_weights() requires compatible fit-time sample design "
2242-
"weights for in-place replay. This can happen because "
2243-
"store_fit_metadata is missing/incompatible, or because you're "
2244-
"scoring a different sample; use predict_weights(data=...) "
2245-
"for different samples."
2246-
)
2247-
2248-
dropped_target_weights: pd.Series | None = None
2249-
if na_action == "drop":
2250-
sample_df, sample_weights = balance_util.drop_na_rows(
2251-
sample_df, sample_weights, "sample"
2252-
)
2253-
target_df, dropped_target_weights = balance_util.drop_na_rows(
2254-
target_df,
2255-
_assert_type(bf._sf_target).df_weights.iloc[:, 0],
2256-
"target",
2257-
)
2258-
elif na_action == "add_indicator":
2259-
sample_df = pd.DataFrame(_safe_fillna_and_infer(sample_df, "__NaN__"))
2260-
target_df = pd.DataFrame(_safe_fillna_and_infer(target_df, "__NaN__"))
2261-
else:
2262-
raise ValueError(
2263-
f"Rake model has invalid na_action metadata '{na_action}' for predict_weights()."
2264-
)
2265-
sample_df = sample_df.astype(str)
2266-
if m_fit.shape != m_sample.shape:
2267-
raise ValueError(
2268-
"Rake model metadata has incompatible fitted and sample table shapes."
2269-
)
2270-
2271-
category_maps = [{cat: i for i, cat in enumerate(cats)} for cats in categories]
2272-
code_columns = []
2273-
for column, cat_map in zip(variables, category_maps):
2274-
codes = sample_df[column].map(cat_map)
2275-
if bool(codes.isna().any()):
2276-
raise ValueError(
2277-
"Rake predict_weights() found rows that do not map to stored fit-time "
2278-
"categories. Re-fit with compatible covariates."
2279-
)
2280-
code_columns.append(codes.astype(int).to_numpy())
2281-
code_index = tuple(code_columns)
2282-
2283-
if source is not None:
2284-
logger.warning(
2285-
"Rake predict_weights(data=...): replaying fitted rake "
2286-
"artifacts on a different sample is a transfer operation, "
2287-
"not an exact fit. The stored cell-ratio surface "
2288-
"(m_fit / m_sample) encodes the *training* target's "
2289-
"marginal distribution; applying it to a new sample only "
2290-
"produces weights calibrated to that *training* target, "
2291-
"rescaled to the new target's total weight. The new "
2292-
"target's marginals are NOT re-balanced. Predictions are "
2293-
"therefore only valid when (a) the scoring sample's joint "
2294-
"distribution over the rake variables is similar to the "
2295-
"training sample's, AND (b) the scoring target's marginal "
2296-
"distribution is similar to the training target's. "
2297-
"Re-fit rake on the scoring sample/target for exact "
2298-
"marginal matching."
2299-
)
2300-
m_sample_at_cells = m_sample[code_index]
2301-
if bool((m_sample_at_cells <= 0).any()):
2302-
raise ValueError(
2303-
"Rake predict_weights() encountered sample rows in joint cells with "
2304-
"zero fit-time sample mass (m_sample==0). Re-fit rake on data with "
2305-
"compatible joint support."
2306-
)
2307-
# Compute ratios only for the scored cells. Avoids materializing a
2308-
# dense array the size of the full contingency table on every call
2309-
# (relevant for high-cardinality rakes).
2310-
cell_ratios = m_fit[code_index] / m_sample_at_cells
2311-
raw = pd.Series(
2312-
sample_weights.to_numpy() * cell_ratios,
2313-
index=sample_weights.index,
2314-
dtype=float,
2315-
)
2316-
target_weights = model.get("training_target_weights")
2317-
if source is None and not isinstance(target_weights, pd.Series):
2318-
raise ValueError(
2319-
"Rake predict_weights() requires compatible fit-time target design "
2320-
"weights for in-place replay. This can happen because "
2321-
"store_fit_metadata is missing/incompatible, or because you're "
2322-
"scoring a different sample; use predict_weights(data=...) "
2323-
"for different samples."
2324-
)
2325-
if source is not None:
2326-
if na_action == "drop" and isinstance(dropped_target_weights, pd.Series):
2327-
target_sum = float(dropped_target_weights.sum())
2328-
else:
2329-
target_sum = float(
2330-
_assert_type(bf._sf_target).df_weights.iloc[:, 0].sum()
2331-
)
2332-
elif isinstance(target_weights, pd.Series):
2333-
target_sum = float(target_weights.sum())
2334-
elif na_action == "drop" and isinstance(dropped_target_weights, pd.Series):
2335-
target_sum = float(dropped_target_weights.sum())
2336-
else:
2337-
target_sum = float(_assert_type(bf._sf_target).df_weights.iloc[:, 0].sum())
2338-
predicted = balance_adjustment.trim_weights(
2339-
raw,
2340-
target_sum_weights=target_sum,
2341-
weight_trimming_mean_ratio=model.get("weight_trimming_mean_ratio"),
2342-
weight_trimming_percentile=model.get("weight_trimming_percentile"),
2343-
keep_sum_of_weights=bool(model.get("keep_sum_of_weights", True)),
2344-
)
23452130
weight_name = getattr(_assert_type(bf.weight_series), "name", None)
2346-
if na_action == "drop":
2347-
predicted_full = pd.Series(
2348-
np.nan, index=sample_weights_full.index, dtype=float
2349-
).rename(predicted.name)
2350-
predicted_full.loc[predicted.index] = predicted.to_numpy()
2351-
predicted = predicted_full
23522131
return cast(
23532132
pd.Series,
23542133
self._align_to_index(

0 commit comments

Comments
 (0)