Problem
DML cross-fitting currently reuses a custom learner template when copy.deepcopy() fails. That breaks the out-of-fold isolation contract for a stateful or warm-start learner: state from an earlier training complement, including observations that are evaluation data in another fold, can affect later predictions.
There is a second silent variant: a custom __deepcopy__ can return self. The current code accepts that object as a fold learner without warning.
Reproduction
At current main (e11d414), I used an eight-observation regression problem with two deterministic folds and a learner that accumulates all outcomes passed to fit().
A fresh learner per fold should produce:
fold ids: [0, 1, 0, 0, 0, 1, 1, 1]
fresh OOF: [4.75, 2.25, 4.75, 4.75, 4.75, 2.25, 2.25, 2.25]
An uncopyable learner currently produces:
actual OOF: [4.75, 3.50, 4.75, 4.75, 4.75, 3.50, 3.50, 3.50]
max difference: 1.25
template seen: 8 outcomes
It emits a warning saying it is reusing the instance. A learner whose __deepcopy__ returns self produces the same contaminated OOF predictions and template mutation, but emits no warning.
The direct path is diff_diff/_crossfit.py::_fresh_learner (lines 61-92), which catches a copy failure and returns the supplied template. cross_fit_predict() then calls it immediately before each fold fit (around lines 520-585). DMLDiD calls this path for both propensity and outcome nuisance learners. The public documentation currently accepts any duck-typed learner with fit plus predict or predict_proba (docs/api/dml_did.rst, learner configuration).
The existing tests/test_crossfit.py::test_copy_failure_warns_loudly codifies the warning-and-reuse behaviour.
Proposed boundary
Make learner isolation fail closed:
- A cross-fit learner template must deep-copy to a distinct top-level object for every fold.
- If copying raises, or returns the original object, raise a targeted
TypeError rather than fitting the template.
- Preflight all fold clones before fitting any clone, so a later copy failure cannot leave an earlier fold fitted. This retains at most
n_folds unfitted clones temporarily.
- Include the caller/nuisance context and fold index, learner class, and copy exception class in the error. Do not expose the foreign exception message.
- Capture only the foreign exception class inside the
except block, then raise after leaving it. raise ... from None is insufficient for the sanitisation boundary because the original exception remains reachable through __context__.
- Document that custom
__deepcopy__ implementations are trusted to isolate nested mutable state. Top-level identity checking cannot prove arbitrary nested aliases without a new clone/factory protocol.
Acceptance criteria
- An uncopyable learner and a self-returning
__deepcopy__ fail before any learner fit() call.
- A second-copy failure proves the clone preflight is transactional.
- Copyable stateful learners match an independent fresh-instance OOF oracle.
- Panel and repeated-cross-section DML paths cover propensity and outcome learner failures.
- The sanitised exception contains no foreign message in
args, __context__, __cause__, or formatted traceback.
- Existing built-in learner outputs remain unchanged.
- The learner API documentation and methodology registry describe the cloneability requirement, with a changelog fragment.
This changes the current warning-and-reuse fallback, so I would like to confirm the boundary before preparing a narrowly scoped PR.
Problem
DML cross-fitting currently reuses a custom learner template when
copy.deepcopy()fails. That breaks the out-of-fold isolation contract for a stateful or warm-start learner: state from an earlier training complement, including observations that are evaluation data in another fold, can affect later predictions.There is a second silent variant: a custom
__deepcopy__can returnself. The current code accepts that object as a fold learner without warning.Reproduction
At current
main(e11d414), I used an eight-observation regression problem with two deterministic folds and a learner that accumulates all outcomes passed tofit().A fresh learner per fold should produce:
An uncopyable learner currently produces:
It emits a warning saying it is reusing the instance. A learner whose
__deepcopy__returnsselfproduces the same contaminated OOF predictions and template mutation, but emits no warning.The direct path is
diff_diff/_crossfit.py::_fresh_learner(lines 61-92), which catches a copy failure and returns the supplied template.cross_fit_predict()then calls it immediately before each fold fit (around lines 520-585).DMLDiDcalls this path for both propensity and outcome nuisance learners. The public documentation currently accepts any duck-typed learner withfitpluspredictorpredict_proba(docs/api/dml_did.rst, learner configuration).The existing
tests/test_crossfit.py::test_copy_failure_warns_loudlycodifies the warning-and-reuse behaviour.Proposed boundary
Make learner isolation fail closed:
TypeErrorrather than fitting the template.n_foldsunfitted clones temporarily.exceptblock, then raise after leaving it.raise ... from Noneis insufficient for the sanitisation boundary because the original exception remains reachable through__context__.__deepcopy__implementations are trusted to isolate nested mutable state. Top-level identity checking cannot prove arbitrary nested aliases without a new clone/factory protocol.Acceptance criteria
__deepcopy__fail before any learnerfit()call.args,__context__,__cause__, or formatted traceback.This changes the current warning-and-reuse fallback, so I would like to confirm the boundary before preparing a narrowly scoped PR.