Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 91 additions & 22 deletions .github/workflows/pypi_upload.yml
Original file line number Diff line number Diff line change
@@ -1,33 +1,102 @@
name: Publish to PyPI

on:
push:
branches:
- main
workflow_run:
workflows: ['Estimator tests']
types: [completed]
branches: [main]

permissions:
contents: read

concurrency:
group: pypi-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: false

jobs:
gate:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
github.event.workflow_run.head_repository.id == github.event.repository.id
runs-on: ubuntu-24.04
permissions:
contents: read
actions: read
checks: read
outputs:
ready: ${{ steps.verify.outputs.ready }}
sha: ${{ steps.verify.outputs.sha }}
steps:
- name: Verify the completed workflow and exact main revision
id: verify
env:
GH_TOKEN: ${{ github.token }}
run: |
python3 - <<'PY'
import json, os, re
from urllib.request import Request, urlopen
event = json.load(open(os.environ['GITHUB_EVENT_PATH']))
repo, repo_id = os.environ['GITHUB_REPOSITORY'], os.environ['GITHUB_REPOSITORY_ID']
def same(value):
return value.get('full_name', '').lower() == repo.lower() and str(value.get('id')) == repo_id
def require(condition, message):
if not condition:
raise SystemExit('Release blocked: ' + message)
def api(path):
request = Request('https://api.github.com/repos/' + repo + path,
headers={'Authorization': 'Bearer ' + os.environ['GH_TOKEN'],
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'})
with urlopen(request, timeout=30) as response:
return json.load(response)
run = api('/actions/runs/' + str(event['workflow_run']['id']))
sha = run['head_sha']
require(same(event['repository']) and same(run['repository']) and same(run['head_repository']), 'wrong source repository')
require(run['name'] == 'Estimator tests' and run['path'].split('@')[0] == '.github/workflows/tests.yml', 'wrong workflow')
require(run['event'] == 'push' and run['head_branch'] == 'main', 'a main push is required')
require(run['status'] == 'completed' and run['conclusion'] == 'success', 'tests have not succeeded')
require(re.fullmatch('[0-9a-f]{40}', sha) and sha == event['workflow_run']['head_sha'], 'wrong tested SHA')
suite = api('/check-suites/' + str(run['check_suite_id']))
require(suite['app']['id'] == 15368 and suite['app']['slug'] == 'github-actions', 'untrusted check source app')
require(suite['head_sha'] == sha and suite['conclusion'] == 'success', 'wrong or unsuccessful test suite')
ready = api('/branches/main')['commit']['sha'] == sha
message = ('Verified successful same-repository main-push Estimator tests at ' + sha
if ready else 'Skipped: tested commit is no longer the main head.')
print(message)
with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as stream:
stream.write(message + '\n')
with open(os.environ['GITHUB_OUTPUT'], 'a') as stream:
stream.write('ready=' + str(ready).lower() + '\nsha=' + sha + '\n')
PY

build:
runs-on: ubuntu-latest
needs: gate
if: needs.gate.outputs.ready == 'true'
runs-on: ubuntu-24.04
permissions:
contents: read
env:
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v2
- id: Python_pip
name: Python dependencies
uses: actions/setup-python@v2
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
python-version: "3.8"
cache: "pip"
- run: pip install -r requirements.txt

- name: Build Package
run: |
python setup.py sdist

- name: Upload to PyPi
ref: ${{ needs.gate.outputs.sha }}
persist-credentials: false
- name: Python dependencies
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.8'
cache: pip
- run: python -m pip install -r requirements.txt wheel
- name: Build the tested package
run: python setup.py sdist bdist_wheel
- name: Check distribution metadata
run: python -m twine check dist/*
- name: Upload to PyPI
env:
TWINE_USERNAME: ${{ secrets.PYPI_USER}}
TWINE_USERNAME: ${{ secrets.PYPI_USER }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
sudo apt-get install tree
tree .
twine upload dist/*
run: twine upload --skip-existing dist/*
120 changes: 120 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
name: Estimator tests

on:
push:
pull_request:

permissions:
contents: read

concurrency:
group: estimator-tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
python:
name: Estimators (Python ${{ matrix.python }})
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
python: ['3.8', '3.12']
env:
OPENBLAS_NUM_THREADS: '1'
OMP_NUM_THREADS: '1'
MKL_NUM_THREADS: '1'
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: ${{ matrix.python }}
- run: python -m pip install pytest .
- run: python -m pytest tests -q

csdid:
name: CSDID integration
runs-on: ubuntu-24.04
timeout-minutes: 15
env:
OPENBLAS_NUM_THREADS: '1'
OMP_NUM_THREADS: '1'
MKL_NUM_THREADS: '1'
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
path: python-drdid
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
repository: d2cml-ai/csdid
path: csdid
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.12'
- name: Install both checked-out packages
run: python -m pip install pytest ./python-drdid ./csdid
- name: Compare actual downstream kernels on common inputs
run: |
git -C csdid rev-parse HEAD
python python-drdid/scripts/check_csdid_integration.py
- name: Run downstream package regression suite
working-directory: csdid
run: python -m pytest csdid/test_csdid -m 'not slow' -q

current-r:
name: Current R DRDID parity
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
OPENBLAS_NUM_THREADS: '1'
OMP_NUM_THREADS: '1'
MKL_NUM_THREADS: '1'
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
path: python-drdid
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
repository: pedrohcgs/DRDID
path: r-drdid
fetch-depth: 0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.12'
- run: python -m pip install pytest ./python-drdid
- uses: r-lib/actions/setup-r@f9a764fea8d5c63df6ef9a5c7795bf7deb5d7e05 # v2
with:
r-version: release
use-public-rspm: true
- uses: r-lib/actions/setup-r-dependencies@f9a764fea8d5c63df6ef9a5c7795bf7deb5d7e05 # v2
with:
working-directory: python-drdid
packages: |
local::${{ github.workspace }}/r-drdid
any::pkgload
any::jsonlite
any::digest
dependencies: '"hard"'
install-pandoc: false
install-quarto: false
- name: Record sources and generate current R references
run: |
mkdir -p report
git -C r-drdid rev-parse HEAD > report/r-revision.txt
git -C python-drdid rev-parse HEAD > report/python-revision.txt
Rscript python-drdid/tests/r_ref/export.R r-drdid report/current-r.json
- name: Compare full ATT, influence functions and analytical inference
working-directory: python-drdid
env:
DRDID_R_REFERENCE: ${{ github.workspace }}/report/current-r.json
run: |
DRDID_R_REVISION=$(git -C ../r-drdid rev-parse HEAD) \
python -m pytest tests/test_r_parity.py -q --junitxml=../report/parity.xml
- name: Require review of changes to the complete upstream tree
if: always()
run: python python-drdid/scripts/check_r_source.py r-drdid
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
if: always()
with:
name: current-r-parity
path: report/
retention-days: 14
54 changes: 51 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,57 @@
# DRDID in python
# DRDID in Python

See the original [`R package`](https://github.com/pedrohcgs/DRDID/tree/master)
Traditional doubly robust, normalized inverse probability weighted, and outcome
regression difference-in-differences estimators for panel data and repeated cross
sections, following [R DRDID](https://github.com/pedrohcgs/DRDID).

## Installation

```sh
pip install drdid
```
pip install git+https://github.com/d2cml-ai/DRDID

For the development version:

```sh
pip install git+https://github.com/d2cml-ai/DRDIDpy.git
```

## Estimators and alignment

The existing functions return `(att, influence)`, with one influence contribution
per observation:

| Module | Panel | Repeated cross sections |
|---|---|---|
| `drdid.drdid` | `drdid_panel` | `drdid_rc` |
| `drdid.ipwd_did` | `std_ipw_did_panel` | `std_ipw_did_rc` |
| `drdid.reg_did` | `reg_did_panel` | `reg_did_rc` |

`covariates=None` fits an intercept-only model. Python retains its historical
convention of adding an intercept unless the first supplied column is all ones.
R's low-level functions use exactly the supplied design: include that same
intercept when comparing. Weights default to ones and are normalized to mean one.
DR and IPW accept `trim_level=0.995`; only controls at or above the fitted-score
threshold are trimmed, after capping scores at `1 - 1e-6`, matching R.

```python
from drdid.drdid import drdid_panel

att, influence = drdid_panel(y1, y0, treated, covariates=X, i_weights=weights)
```

Comparisons require the same sample, outcome, design, weights, estimator and
trimming. These DR functions correspond to R's **traditional** estimators. R's
high-level `drdid()` defaults to improved/IPT estimation; select `estMethod="trad"`
when comparing. The IPW functions here are normalized (Hajek), not R's
nonnormalized IPW variant. Improved/IPT, alternative RC estimators, TWFE, R's
formula preprocessing and refit bootstrap API are not implemented here.

This package returns the ATT and influence function rather than an inference
object. R's analytical standard error can be reconstructed as
`np.linalg.norm(influence - influence.mean()) / len(influence)`; its pointwise
95% limits are `att +/- 1.96 * se`. CSDID adds its own group-time preprocessing,
aggregation, overlap checks and inference layers.

See [the validation and release notes](docs/r-csdid-alignment.md) for the tested
R source revision, regression evidence and the CSDID integration sequence.
84 changes: 84 additions & 0 deletions docs/r-csdid-alignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Alignment with R DRDID and Python CSDID

The reviewed R baseline is DRDID 1.4.0 at
[`eaac3b49`](https://github.com/pedrohcgs/DRDID/commit/eaac3b49cd225d0d7706c633858555a5f204e21e).
The prior Python baseline is 1.1.6 / `75cac04e`. The six existing Python kernels
are traditional DR, normalized IPW and regression, each for panel and repeated
cross-section data. This change preserves their tuple return and methods.

The correction addresses observed failures: omitted weights were converted to an
array before the `None` check in three functions; RC DR added rather than
subtracted pre-period nuisance contributions; propensity trimming was missing;
and the propensity Hessian used an earlier IRLS covariance rather than the final
capped probabilities. Inputs and weighted fit designs are now checked before
fitting. All six methods share those checks. Python's existing automatic
intercept convention is retained and explicitly documented.

## Evidence

`tests/r_ref` contains common seeded inputs, 80 fixed configurations, complete R
ATT/influence/SE/interval references and a manifest of hashes. The grid covers
all six methods, omitted/unit/varying/zero weights, intercept-only and covariate
models, controls above trimming thresholds, two thresholds, and outcome unit
changes by factors of `1e-10` and `1e10`. Comparisons use `atol=1e-7, rtol=1e-6`
both on raw outputs and after converting back to original outcome units.
Full influence vectors and nonfinite behavior are
checked, not only point estimates. The input/unit conversion is fixed before
comparison. Ordinary tests need no R installation.

The independent contract tests include closed forms, empirical observation-weight
derivatives with nuisance refitting, weight scaling, zero-mass rows and malformed
inputs. They can expose mistakes even when two implementations share a bug.
The exporter rejects dirty/untracked R checkouts, rebuilds native code and binds
reference metadata to the selected commit, the complete tracked source tree and
exact input/exporter hashes.
The current-R CI job also regenerates references from the official R default
branch. Changes to any tracked R repository file require review,
even when this fixed numerical grid passes. A passing grid does not establish
parity for methods outside the six listed above.

The workflow also installs the proposed DRDID and the current CSDID checkout
together, compares the actual downstream dispatch on 72 common-input cases and
runs CSDID's regression suite. CI runs on Python repository pushes and pull
requests. An R-only push does not start it; new R changes are checked on the next
Python run and still require a reviewed port rather than automatic code changes.

Local replay from the repository root, using a clean R checkout with dependencies
and `pkgload`, `jsonlite`, `digest` installed:

```sh
python -m pip install pytest .
python -m pytest tests -q
Rscript tests/r_ref/export.R /path/to/DRDID /tmp/new-r-reference.json
DRDID_R_REFERENCE=/tmp/new-r-reference.json python -m pytest tests/test_r_parity.py -q
python scripts/check_r_source.py /path/to/DRDID
```

Regenerating accepted references is a review action: preserve the existing input
CSVs and case definitions, inspect the R source/test diff, port applicable
assertions, diagnose numerical changes, and update provenance together. Do not
widen tolerances or remove cases to make an update pass. `generate_inputs.py` is
the original deterministic input recipe, not part of routine reference updates.

## Coordinated delivery

Python CSDID currently keeps local DR/IPW kernels with additional performance
caching, while importing regression kernels from `drdid.reg_did`. Therefore a
change in only one repository cannot certify both packages. The six CSDID routes
must continue to be compared against R did/DRDID, and standalone DRDID against R
DRDID. The coordinated candidates are Python DRDID 1.1.7 and CSDID 0.4.4,
whose dependency pins `drdid==1.1.7`. Future backend upgrades must update the
reviewed peer lock and exact dependency together. Release DRDID first so installing
the new CSDID cannot retain the older standalone backend. CSDID's required CI
builds both reviewed revisions as wheels and tests them together in a fresh
environment, including their full numerical suites and the direct kernel bridge.
Consolidating CSDID's cached local kernels remains separate from validating this
release pair. These validation commands do not publish either package.

CI checks should be required in upstream branch protection. The contributor
cannot change upstream repository rules; adding workflow files alone does not
make those checks mandatory. Publishing is now gated on the successful main
branch estimator workflow and checks out its exact tested commit. CSDID also
checks its published backend against the reviewed peer source before uploading
its own release. Both version updates remain proposals until reviewed and
released; the current PyPI `drdid` 1.1.6 does not contain these fixes.
Loading