Skip to content

Commit 2d2f7da

Browse files
neuralsorcerermeta-codesync[bot]
authored andcommitted
Add BalanceFrame adjustment history (#490)
Summary: Pull Request resolved: #490 Reviewed By: sahil350 Differential Revision: D105962806 Pulled By: talgalili fbshipit-source-id: 814eaf3c8e96723b44ef2578ac4b9ddb90f90ddf
1 parent b9e4c2f commit 2d2f7da

3 files changed

Lines changed: 309 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@
7070
metadata and must be re-fit to use transfer scoring; in-place
7171
`predict_weights()` continues to work on older pickles.
7272

73+
- **`BalanceFrame.adjustment_history` records compound adjustment steps.**
74+
Sequential `adjust()` / `set_fitted_model()` workflows now keep a chronological,
75+
best-effort read-only copy of each adjustment step while preserving `model` as the latest
76+
fitted model for backwards compatibility. Baseline resets such as
77+
`set_as_pre_adjust()` clear the history together with the current model.
78+
7379
## Documentation
7480

7581
- **README cross-link to diff-diff.** New "Design-based inference" parent section in [README.md](https://github.com/facebookresearch/balance/blob/main/README.md) introduces the diff-diff integration above the API tour, with a fenced code snippet (canonical `Sample.from_frame``set_target``adjust``fit_did` workflow) and links to the upstream project. The Docusaurus tutorials index and the website landing page (`HomepageFeatures.js`) gain matching cross-references; `.github/copilot-instructions.md` gets a new review-checklist bullet for changes that touch `balance/interop/diff_diff.py`.

balance/balance_frame.py

Lines changed: 129 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ class BalanceFrame:
153153
_sf_target: SampleFrame | None
154154
# pyre-fixme[13]: Attributes are initialized in _create() / from_frame()
155155
_adjustment_model: dict[str, Any] | None
156+
# pyre-fixme[13]: Attributes are initialized in _create() / from_frame()
157+
_adjustment_history: list[dict[str, Any]]
156158
# pyre-fixme[4]: Attributes are initialized in from_frame() / _create()
157159
# _links is a defaultdict(list) but by convention stores single objects
158160
# (not lists) for the "target" and "unadjusted" keys. The defaultdict
@@ -182,6 +184,67 @@ def _sync_sampleframe_state_from_responder(self, responder: SampleFrame) -> None
182184
# pyrefly: ignore [missing-attribute]
183185
self._df_dtypes = responder._df_dtypes
184186

187+
@staticmethod
188+
def _copy_adjustment_history_from(
189+
source: Any, *, deep: bool = False
190+
) -> list[dict[str, Any]]:
191+
"""Return adjustment history copied from *source*.
192+
193+
Older objects, tests, or deserialized instances may not have the
194+
private attribute yet; those are treated as having no history.
195+
196+
``deep=False`` (default) performs an inexpensive structural copy of the
197+
history list and per-entry dictionaries while preserving model object
198+
references. This is suitable for internal object construction paths and
199+
avoids eagerly duplicating large fitted artifacts.
200+
201+
``deep=True`` is intended for public read boundaries. It tries to deep
202+
copy each entry, but gracefully falls back to a shallow per-entry copy
203+
when model artifacts are not deepcopy-safe.
204+
"""
205+
history = getattr(source, "_adjustment_history", [])
206+
if not isinstance(history, list):
207+
return []
208+
209+
copied: list[dict[str, Any]] = []
210+
for entry in history:
211+
if not isinstance(entry, dict):
212+
continue
213+
if deep:
214+
try:
215+
copied.append(cast(dict[str, Any], copy.deepcopy(entry)))
216+
continue
217+
except (TypeError, RuntimeError, copy.Error):
218+
# Best-effort fallback: copy the entry dict and also
219+
# copy nested model mappings to keep caller mutations
220+
# from leaking back into internal state.
221+
fallback = dict(entry)
222+
model_obj = fallback.get("model")
223+
if isinstance(model_obj, dict):
224+
fallback["model"] = dict(model_obj)
225+
copied.append(fallback)
226+
continue
227+
copied.append(dict(entry))
228+
return copied
229+
230+
def _clear_adjustment_state(self) -> None:
231+
"""Clear fitted adjustment state and its chronological history."""
232+
self._adjustment_model = None
233+
self._adjustment_history = []
234+
# pyrefly: ignore [missing-attribute]
235+
self._links.pop("unadjusted", None)
236+
237+
def _append_adjustment_history_entry(self, method: str, model: Any) -> None:
238+
"""Append one adjustment-history entry.
239+
240+
The model payload is stored by reference to avoid expensive deep copies
241+
of large fitted artifacts (estimators/matrices). Public callers should
242+
use :attr:`adjustment_history`, which returns a copied view.
243+
"""
244+
if not hasattr(self, "_adjustment_history"):
245+
self._adjustment_history = []
246+
self._adjustment_history.append({"method": method, "model": model})
247+
185248
@property
186249
def _df_dtypes(self) -> pd.Series | None:
187250
"""Original dtypes, delegated to ``_sf_sample._df_dtypes``."""
@@ -365,6 +428,7 @@ def _create(
365428
instance._sf_sample = sample # same object initially
366429
instance._sf_target = target
367430
instance._adjustment_model = None
431+
instance._adjustment_history = []
368432
instance._links = collections.defaultdict(list)
369433
if target is not None:
370434
# pyrefly: ignore [unsupported-operation]
@@ -523,12 +587,21 @@ def set_target(
523587
if isinstance(target, BalanceFrame):
524588
# BalanceFrame / Sample path: return a deep copy (immutable)
525589
new_copy = deepcopy(self)
526-
# pyrefly: ignore [unsupported-operation]
527-
new_copy._links["target"] = target
528590
BalanceFrame._validate_covariate_overlap(
529-
new_copy._sf_sample, target._sf_sample
591+
new_copy._sf_sample_pre_adjust, target._sf_sample
530592
)
593+
if new_copy.is_adjusted:
594+
logger.warning(
595+
"Replacing target on an adjusted object resets responder "
596+
"weights to pre-adjust values and discards current "
597+
"adjustment results on the returned copy."
598+
)
599+
new_copy._sf_sample = new_copy._sf_sample_pre_adjust
600+
new_copy._clear_adjustment_state()
601+
# pyrefly: ignore [unsupported-operation]
602+
new_copy._links["target"] = target
531603
new_copy._sf_target = target._sf_sample
604+
new_copy._sync_sampleframe_state_from_responder(new_copy._sf_sample)
532605
return new_copy
533606

534607
if isinstance(target, SampleFrame):
@@ -551,9 +624,7 @@ def set_target(
551624
self._links["target"] = target
552625
# Reset adjustment state — old adjustment is no longer valid.
553626
self._sf_sample = self._sf_sample_pre_adjust
554-
self._adjustment_model = None
555-
# pyrefly: ignore [missing-attribute]
556-
self._links.pop("unadjusted", None)
627+
self._clear_adjustment_state()
557628
self._sync_sampleframe_state_from_responder(self._sf_sample)
558629
return self
559630
else:
@@ -615,9 +686,7 @@ def set_as_pre_adjust(self, *, inplace: bool = False) -> Self:
615686
bf._links["target"] = self._links["target"]
616687
bf._sf_sample_pre_adjust = frozen
617688
bf._sf_sample = frozen
618-
bf._adjustment_model = None
619-
# pyrefly: ignore [missing-attribute]
620-
bf._links.pop("unadjusted", None)
689+
bf._clear_adjustment_state()
621690
bf._sync_sampleframe_state_from_responder(frozen)
622691
return bf
623692

@@ -774,9 +843,6 @@ def _build_adjusted_frame(
774843
raw_model = result.get("model")
775844
# Defensive copy: the weighting function may retain a reference to the
776845
# dict it returned, so mutating it here could cause surprising side effects.
777-
# TODO: Track adjustment history — currently only the latest model is
778-
# stored. A future enhancement should maintain a list of
779-
# (method, model_dict) tuples for each adjustment step.
780846
new_bf._adjustment_model = (
781847
dict(raw_model) if isinstance(raw_model, dict) else raw_model
782848
)
@@ -801,6 +867,15 @@ def _build_adjusted_frame(
801867
"training_target_weights",
802868
fit_target_weights,
803869
)
870+
effective_method_name = (
871+
str(adj_model.get("method")) if isinstance(adj_model, dict) else method_name
872+
)
873+
new_bf._adjustment_history = self._copy_adjustment_history_from(
874+
self, deep=False
875+
)
876+
new_bf._append_adjustment_history_entry(
877+
effective_method_name, new_bf._adjustment_model
878+
)
804879
return new_bf
805880

806881
def adjust(
@@ -1145,6 +1220,9 @@ def fit(
11451220
self._sf_sample_pre_adjust = result._sf_sample_pre_adjust
11461221
self._sf_target = result._sf_target
11471222
self._adjustment_model = result._adjustment_model
1223+
self._adjustment_history = self._copy_adjustment_history_from(
1224+
result, deep=False
1225+
)
11481226
self._links = result._links
11491227
self._sync_sampleframe_state_from_responder(self._sf_sample)
11501228
return self
@@ -1310,6 +1388,9 @@ def set_fitted_model(self, fitted: BalanceFrame, *, inplace: bool = True) -> Sel
13101388

13111389
# Store the model and set adjustment state
13121390
bf._adjustment_model = dict(model)
1391+
method_name = str(model.get("method", "set_fitted_model"))
1392+
bf._adjustment_history = self._copy_adjustment_history_from(self, deep=False)
1393+
bf._append_adjustment_history_entry(method_name, bf._adjustment_model)
13131394
# pyrefly: ignore [unsupported-operation]
13141395
bf._links["unadjusted"] = type(self)._create(
13151396
sample=bf._sf_sample_pre_adjust,
@@ -2592,6 +2673,33 @@ def model(self) -> dict[str, Any] | None:
25922673
"""
25932674
return self._adjustment_model
25942675

2676+
@property
2677+
def adjustment_history(self) -> list[dict[str, Any]]:
2678+
"""Chronological adjustment model history.
2679+
2680+
Returns a best-effort read-only copy of each recorded adjustment step
2681+
so callers can inspect compound reweighting workflows without mutating
2682+
internal state. The :attr:`model` property remains the latest
2683+
adjustment model for backward compatibility.
2684+
2685+
Deep-copy is attempted per entry; when a payload is not deepcopy-safe,
2686+
the method falls back to copying the step dictionary and nested model
2687+
mapping (when present).
2688+
2689+
Examples:
2690+
>>> import pandas as pd
2691+
>>> from balance.sample_frame import SampleFrame
2692+
>>> from balance.balance_frame import BalanceFrame
2693+
>>> resp = SampleFrame.from_frame(
2694+
... pd.DataFrame({"id": [1, 2], "x": [1.0, 2.0], "weight": [1.0, 1.0]}))
2695+
>>> tgt = SampleFrame.from_frame(
2696+
... pd.DataFrame({"id": [3, 4], "x": [1.5, 2.5], "weight": [1.0, 1.0]}))
2697+
>>> bf = BalanceFrame(sample=resp, target=tgt).adjust(method="null")
2698+
>>> len(bf.adjustment_history)
2699+
1
2700+
"""
2701+
return self._copy_adjustment_history_from(self, deep=True)
2702+
25952703
# --- Conversion ---
25962704

25972705
@classmethod
@@ -2652,6 +2760,9 @@ def from_sample(cls, sample: Any) -> BalanceFrame:
26522760
sample._links["unadjusted"]
26532761
)
26542762
bf._adjustment_model = sample.model
2763+
bf._adjustment_history = cls._copy_adjustment_history_from(
2764+
sample, deep=False
2765+
)
26552766

26562767
return bf
26572768

@@ -2723,6 +2834,9 @@ def to_sample(self) -> Any:
27232834
# pyrefly: ignore [unsupported-operation]
27242835
result._links["unadjusted"] = unadj_sf
27252836
result._adjustment_model = self._adjustment_model
2837+
result._adjustment_history = self._copy_adjustment_history_from(
2838+
self, deep=False
2839+
)
27262840

27272841
return result
27282842

@@ -3506,6 +3620,9 @@ def trim(
35063620
)
35073621
new_bf._sf_sample_pre_adjust = self._sf_sample_pre_adjust
35083622
new_bf._adjustment_model = self._adjustment_model
3623+
new_bf._adjustment_history = self._copy_adjustment_history_from(
3624+
self, deep=False
3625+
)
35093626
# Preserve existing links (target, unadjusted).
35103627
# pyrefly: ignore [missing-attribute]
35113628
for key, val in self._links.items():

0 commit comments

Comments
 (0)