Skip to content

Commit c810310

Browse files
neuralsorcerermeta-codesync[bot]
authored andcommitted
Add rake and poststratify diagnostics (#530)
Summary: Pull Request resolved: #530 Differential Revision: D112668817 Pulled By: talgalili fbshipit-source-id: 03fd8da0a8bcd1a230de831be1bb439a569c2e9f
1 parent 067e88a commit c810310

7 files changed

Lines changed: 495 additions & 340 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
# 0.23.0 (unreleased)
2+
3+
## New Features
4+
5+
- Diagnostics now include compact model-glance rows for rake and poststratification adjustments, covering rake convergence metadata and persisted poststratification matching-cell metadata when available.
6+
7+
## Documentation
8+
9+
- Added docstring and statistical-method and notebook tutorial examples showing the new rake and poststratification `model_glance` diagnostics output.
10+
11+
## Tests
12+
13+
- Added summary-helper regression coverage for rake and poststratification model diagnostics, including examples that mirror the new docstring outputs.
14+
115
# 0.22.0 (2026-07-15)
216

317
## New Features

balance/summary_utils.py

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class hierarchy.
1818
from __future__ import annotations
1919

2020
import logging
21+
from collections.abc import Sized
2122
from typing import Any
2223

2324
import numpy as np
@@ -161,6 +162,151 @@ def _concat_metric_val_var(
161162
return pd.concat((diagnostics, rows), ignore_index=True)
162163

163164

165+
def _safe_len(value: Any) -> int | float:
166+
"""Return ``len(value)`` when available, otherwise ``np.nan``.
167+
168+
Args:
169+
value: Candidate object to measure.
170+
171+
Returns:
172+
The object's length, or ``np.nan`` when the object is scalar or its
173+
length implementation raises.
174+
"""
175+
if value is None or isinstance(value, (str, bytes)):
176+
return np.nan
177+
if not isinstance(value, Sized):
178+
return np.nan
179+
180+
try:
181+
return len(value)
182+
except Exception:
183+
return np.nan
184+
185+
186+
def _append_rake_model_diagnostics(
187+
diagnostics: pd.DataFrame, model: dict[str, Any]
188+
) -> pd.DataFrame:
189+
"""Append compact diagnostics for a fitted rake model.
190+
191+
Args:
192+
diagnostics: Existing diagnostics table to append to.
193+
model: Rake model metadata dictionary, usually the ``model`` object
194+
returned by ``balance.weighting_methods.rake.rake``.
195+
196+
Returns:
197+
A diagnostics table with any available rake ``model_glance`` rows
198+
appended. Missing optional metadata is skipped except for the rake
199+
convergence flag, which is emitted as ``NaN`` when absent.
200+
201+
Examples:
202+
A rake model with two iterations emits compact convergence rows::
203+
204+
>>> import pandas as pd
205+
>>> diagnostics = pd.DataFrame(columns=["metric", "val", "var"])
206+
>>> model = {
207+
... "method": "rake",
208+
... "converged": 1,
209+
... "iterations": pd.DataFrame({"conv": [0.5, 0.01]}),
210+
... "variables": ["gender", "age_group"],
211+
... }
212+
>>> _append_rake_model_diagnostics(diagnostics, model).to_dict("records")
213+
[{'metric': 'model_glance', 'val': 1, 'var': 'converged'}, {'metric': 'model_glance', 'val': 2, 'var': 'iterations'}, {'metric': 'model_glance', 'val': 0.01, 'var': 'final_conv'}, {'metric': 'model_glance', 'val': 2, 'var': 'n_variables'}]
214+
"""
215+
diagnostics = _concat_metric_val_var(
216+
diagnostics,
217+
"model_glance",
218+
[_coerce_scalar(model.get("converged"))],
219+
["converged"],
220+
)
221+
222+
iterations = model.get("iterations")
223+
if isinstance(iterations, pd.DataFrame):
224+
diagnostics = _concat_metric_val_var(
225+
diagnostics,
226+
"model_glance",
227+
[len(iterations)],
228+
["iterations"],
229+
)
230+
if "conv" in iterations.columns and len(iterations) > 0:
231+
diagnostics = _concat_metric_val_var(
232+
diagnostics,
233+
"model_glance",
234+
[_coerce_scalar(iterations["conv"].iloc[-1])],
235+
["final_conv"],
236+
)
237+
238+
variables = model.get("variables")
239+
if variables is not None:
240+
diagnostics = _concat_metric_val_var(
241+
diagnostics,
242+
"model_glance",
243+
[_safe_len(variables)],
244+
["n_variables"],
245+
)
246+
247+
return diagnostics
248+
249+
250+
def _append_poststratify_model_diagnostics(
251+
diagnostics: pd.DataFrame, model: dict[str, Any]
252+
) -> pd.DataFrame:
253+
"""Append compact diagnostics for a fitted poststratification model.
254+
255+
Args:
256+
diagnostics: Existing diagnostics table to append to.
257+
model: Poststratification model metadata dictionary, usually the
258+
``model`` object returned by
259+
``balance.weighting_methods.poststratify.poststratify``.
260+
261+
Returns:
262+
A diagnostics table with any available poststratification
263+
``model_glance`` rows appended. Rows that require persisted fit
264+
metadata are omitted when that metadata is unavailable.
265+
266+
Examples:
267+
Persisted poststratification metadata emits variable, matching, and
268+
cell-count rows::
269+
270+
>>> import pandas as pd
271+
>>> diagnostics = pd.DataFrame(columns=["metric", "val", "var"])
272+
>>> model = {
273+
... "method": "poststratify",
274+
... "variables": ["gender", "age_group"],
275+
... "strict_matching": True,
276+
... "cell_weight_ratio": pd.Series([0.5, 2.0]),
277+
... }
278+
>>> _append_poststratify_model_diagnostics(diagnostics, model).to_dict("records")
279+
[{'metric': 'model_glance', 'val': 2, 'var': 'n_variables'}, {'metric': 'model_glance', 'val': 1, 'var': 'strict_matching'}, {'metric': 'model_glance', 'val': 2, 'var': 'n_cells'}]
280+
"""
281+
variables = model.get("variables")
282+
if variables is not None:
283+
diagnostics = _concat_metric_val_var(
284+
diagnostics,
285+
"model_glance",
286+
[_safe_len(variables)],
287+
["n_variables"],
288+
)
289+
290+
if "strict_matching" in model:
291+
diagnostics = _concat_metric_val_var(
292+
diagnostics,
293+
"model_glance",
294+
[int(bool(model["strict_matching"]))],
295+
["strict_matching"],
296+
)
297+
298+
cell_weight_ratio = model.get("cell_weight_ratio")
299+
if cell_weight_ratio is not None:
300+
diagnostics = _concat_metric_val_var(
301+
diagnostics,
302+
"model_glance",
303+
[_safe_len(cell_weight_ratio)],
304+
["n_cells"],
305+
)
306+
307+
return diagnostics
308+
309+
164310
def _build_summary(
165311
*,
166312
is_adjusted: bool,
@@ -553,7 +699,11 @@ def _build_diagnostics(
553699
optimizations = pd.DataFrame({"metric": metric, "var": var, "val": val})
554700
diagnostics = pd.concat((diagnostics, optimizations))
555701

556-
# TODO: add model diagnostics for other models
702+
elif model["method"] == "rake":
703+
diagnostics = _append_rake_model_diagnostics(diagnostics, model)
704+
705+
elif model["method"] == "poststratify":
706+
diagnostics = _append_poststratify_model_diagnostics(diagnostics, model)
557707

558708
# ----------------------------------------------------
559709
# Diagnostics on the covariates correction

tests/test_sample_diagnostics_helper.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import pandas as pd
1414
from balance.sample_class import Sample
1515
from balance.summary_utils import (
16+
_append_poststratify_model_diagnostics,
17+
_append_rake_model_diagnostics,
1618
_build_diagnostics,
1719
_build_summary,
1820
_concat_metric_val_var,
@@ -756,3 +758,158 @@ def get_params(self, deep=False):
756758
covars_asmd_main=covars_asmd,
757759
)
758760
assert not out.empty
761+
762+
763+
class _BrokenLen:
764+
def __len__(self) -> int:
765+
raise RuntimeError("length unavailable")
766+
767+
768+
def _minimal_diagnostics_inputs() -> dict[str, Any]:
769+
covars_df = pd.DataFrame({"a": [1, 2], "b": ["x", "y"]})
770+
covars_asmd = pd.DataFrame(
771+
{"self": [0.1], "unadjusted": [0.2], "unadjusted - self": [0.1]},
772+
index=pd.Index(["a"]),
773+
)
774+
775+
return {
776+
"covars_df": covars_df,
777+
"target_covars_df": covars_df.copy(),
778+
"weights_summary": pd.DataFrame({"var": ["design_effect"], "val": [1.0]}),
779+
"covars_asmd": covars_asmd,
780+
"covars_asmd_main": covars_asmd,
781+
}
782+
783+
784+
def test_build_diagnostics_includes_rake_model_glance() -> None:
785+
iterations = pd.DataFrame({"conv": [0.5, 0.01]}, index=pd.Index([0, 1]))
786+
model = {
787+
"method": "rake",
788+
"converged": 1,
789+
"iterations": iterations,
790+
"variables": ["a", "b"],
791+
}
792+
793+
out = _build_diagnostics(
794+
**_minimal_diagnostics_inputs(),
795+
model_dict=model,
796+
)
797+
798+
glance = out[out["metric"] == "model_glance"].set_index("var")["val"]
799+
assert glance["converged"] == 1
800+
assert glance["iterations"] == 2
801+
assert glance["final_conv"] == 0.01
802+
assert glance["n_variables"] == 2
803+
804+
805+
def test_build_diagnostics_handles_sparse_rake_metadata() -> None:
806+
model = {
807+
"method": "rake",
808+
"iterations": pd.DataFrame({"other": [1.0]}),
809+
"variables": _BrokenLen(),
810+
}
811+
812+
out = _build_diagnostics(
813+
**_minimal_diagnostics_inputs(),
814+
model_dict=model,
815+
)
816+
817+
glance = out[out["metric"] == "model_glance"].set_index("var")["val"]
818+
assert np.isnan(float(glance["converged"]))
819+
assert glance["iterations"] == 1
820+
assert "final_conv" not in glance.index
821+
assert np.isnan(float(glance["n_variables"]))
822+
823+
824+
def test_build_diagnostics_treats_scalar_strings_as_missing_lengths() -> None:
825+
for method in ("rake", "poststratify"):
826+
model = {
827+
"method": method,
828+
"variables": "ab",
829+
"cell_weight_ratio": b"xy",
830+
}
831+
832+
out = _build_diagnostics(
833+
**_minimal_diagnostics_inputs(),
834+
model_dict=model,
835+
)
836+
837+
glance = out[out["metric"] == "model_glance"].set_index("var")["val"]
838+
assert np.isnan(float(glance["n_variables"]))
839+
if method == "poststratify":
840+
assert np.isnan(float(glance["n_cells"]))
841+
842+
843+
def test_build_diagnostics_includes_poststratify_model_glance() -> None:
844+
model = {
845+
"method": "poststratify",
846+
"variables": ["a"],
847+
"strict_matching": True,
848+
"cell_weight_ratio": pd.Series([0.5, 2.0], index=["x", "y"]),
849+
}
850+
851+
out = _build_diagnostics(
852+
**_minimal_diagnostics_inputs(),
853+
model_dict=model,
854+
)
855+
856+
glance = out[out["metric"] == "model_glance"].set_index("var")["val"]
857+
assert glance["n_variables"] == 1
858+
assert glance["strict_matching"] == 1
859+
assert glance["n_cells"] == 2
860+
861+
862+
def test_build_diagnostics_handles_sparse_poststratify_metadata() -> None:
863+
model = {
864+
"method": "poststratify",
865+
"variables": _BrokenLen(),
866+
"strict_matching": False,
867+
"cell_weight_ratio": _BrokenLen(),
868+
}
869+
870+
out = _build_diagnostics(
871+
**_minimal_diagnostics_inputs(),
872+
model_dict=model,
873+
)
874+
875+
glance = out[out["metric"] == "model_glance"].set_index("var")["val"]
876+
assert np.isnan(float(glance["n_variables"]))
877+
assert glance["strict_matching"] == 0
878+
assert np.isnan(float(glance["n_cells"]))
879+
880+
881+
def test_rake_model_diagnostics_docstring_example_output() -> None:
882+
diagnostics = pd.DataFrame(columns=["metric", "val", "var"])
883+
model = {
884+
"method": "rake",
885+
"converged": 1,
886+
"iterations": pd.DataFrame({"conv": [0.5, 0.01]}),
887+
"variables": ["gender", "age_group"],
888+
}
889+
890+
out = _append_rake_model_diagnostics(diagnostics, model)
891+
892+
assert out.to_dict("records") == [
893+
{"metric": "model_glance", "val": 1, "var": "converged"},
894+
{"metric": "model_glance", "val": 2, "var": "iterations"},
895+
{"metric": "model_glance", "val": 0.01, "var": "final_conv"},
896+
{"metric": "model_glance", "val": 2, "var": "n_variables"},
897+
]
898+
899+
900+
def test_poststratify_model_diagnostics_docstring_example_output() -> None:
901+
diagnostics = pd.DataFrame(columns=["metric", "val", "var"])
902+
model = {
903+
"method": "poststratify",
904+
"variables": ["gender", "age_group"],
905+
"strict_matching": True,
906+
"cell_weight_ratio": pd.Series([0.5, 2.0]),
907+
}
908+
909+
out = _append_poststratify_model_diagnostics(diagnostics, model)
910+
911+
assert out.to_dict("records") == [
912+
{"metric": "model_glance", "val": 2, "var": "n_variables"},
913+
{"metric": "model_glance", "val": 1, "var": "strict_matching"},
914+
{"metric": "model_glance", "val": 2, "var": "n_cells"},
915+
]

0 commit comments

Comments
 (0)