Skip to content

DML cross-fitting can reuse stateful custom learners across folds #814

Description

@shawcharles

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:

  1. A cross-fit learner template must deep-copy to a distinct top-level object for every fold.
  2. If copying raises, or returns the original object, raise a targeted TypeError rather than fitting the template.
  3. 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.
  4. Include the caller/nuisance context and fold index, learner class, and copy exception class in the error. Do not expose the foreign exception message.
  5. 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__.
  6. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions