Skip to content

Functional API

The functional API mirrors the Stata command surface: a single call that takes the panel, returns a dict.

from did_multiplegt_stat import did_multiplegt_stat, summary_did_multiplegt_stat

did_multiplegt_stat

did_multiplegt_stat

did_multiplegt_stat(df: DataFrame, Y: str, ID: str, Time: str, D: str, Z: str | None = None, estimator: str | Sequence[str] | None = None, order: int | list[int] = 1, noextrapolation: bool = False, placebo: int = 0, switchers: str | None = None, disaggregate: bool = False, as_vs_was: bool = False, exact_match: bool = False, by: Sequence[str] | None = None, by_fd: int | None = None, by_baseline: int | None = None, other_treatments: Sequence[str] | None = None, cluster: str | None = None, weight: str | None = None, controls: Sequence[str] | None = None, cross_fitting: int = 0, trimming: float = 0, on_placebo_sample: bool = False, bootstrap: int = 0, twfe: bool | dict[str, Any] = False, seed: int = 0, cross_validation: dict[str, Any] | None = None, iv_method: str = 'manual', cf_folds_file: str | None = None, asinstata: bool = False, model_deltay=None, model_stayer=None, **legacy_options) -> dict[str, Any]

Python interface for did_multiplegt_stat.

Parameters:

Name Type Description Default
df DataFrame - Panel data in long format.
required
Y str - Column names for outcome, unit ID, time, treatment.
required
ID str - Column names for outcome, unit ID, time, treatment.
required
Time str - Column names for outcome, unit ID, time, treatment.
required
D str - Column names for outcome, unit ID, time, treatment.
required
Z str, optional - Instrument variable for IV-WAS.
None
estimator str or list - 'as', 'was', 'iv-was'.
None
as_vs_was bool - Test equality of the AS and WAS estimators.
False
order int or list of 1/4/8 ints - Polynomial order(s). 8 ints for IV: first 4=first-stage, last 4=reduced-form.
1
placebo int - Number of placebos (0 = none).
0
iv_method str - IV regression package: 'manual' (default, two OLS), 'linearmodels', or 'econtools'.
'manual'
controls list of str - Control variables.
None
cross_fitting int - Number of cross-fitting folds (0 = none).
0
trimming float - Propensity score trimming threshold (0 = none).
0
on_placebo_sample bool - Estimate only on stayer sample.
False
bootstrap int - Number of bootstrap replications (0 = none).
0
twfe bool or dict - Compare with TWFE regression. Dict keys: same_sample, percentile.
False
cross_validation dict - CV options (algorithm, tolerance, max_k, seed, kfolds).
None
by_baseline int - Number of quantile bins for baseline treatment.
None
cf_folds_file str, optional - Path to CSV with cross-fitting fold IDs exported by Stata.

Columns: pairwise, placebo_index, estimator_type, ID_XX, cf_sample_id. When provided, fold assignments are read from this file instead of generated internally.

None
asinstata bool

(custom Newton-Raphson logit + statsmodels OLS). If False (default), use scikit-learn LinearRegression / LogisticRegression for all OLS and logit estimations. Note: changing this flag changes numerical results; Stata parity tests require asinstata=True.

False - If True, use Stata-faithful regressions
model_deltay object, optional - Custom regression model for E[DeltaY|D1,S=0].

Must implement .fit(X, y) and .predict(X) (sklearn-style). When provided, overrides the default OLS model (regardless of asinstata). Example: RandomForestRegressor(n_estimators=100).

None
model_stayer object, optional - Custom classification model for P(stayer|D1).

Must implement .fit(X, y) and .predict_proba(X) (sklearn-style). When provided, overrides the default logit model (regardless of asinstata). Example: RandomForestClassifier(n_estimators=100).

None
Source code in src/did_multiplegt_stat/core.py
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
def did_multiplegt_stat(
    df: pd.DataFrame,
    Y: str, ID: str, Time: str, D: str,
    Z: str | None = None,
    estimator: str | Sequence[str] | None = None,
    order: int | list[int] = 1,
    noextrapolation: bool = False,
    placebo: int = 0,
    switchers: str | None = None,
    disaggregate: bool = False,
    as_vs_was: bool = False,
    exact_match: bool = False,
    by: Sequence[str] | None = None,
    by_fd: int | None = None,
    by_baseline: int | None = None,
    other_treatments: Sequence[str] | None = None,
    cluster: str | None = None,
    weight: str | None = None,
    controls: Sequence[str] | None = None,
    cross_fitting: int = 0,
    trimming: float = 0,
    on_placebo_sample: bool = False,
    bootstrap: int = 0,
    twfe: bool | dict[str, Any] = False,
    seed: int = 0,
    cross_validation: dict[str, Any] | None = None,
    iv_method: str = "manual",
    cf_folds_file: str | None = None,
    asinstata: bool = False,
    model_deltay=None,
    model_stayer=None,
    **legacy_options,
) -> dict[str, Any]:
    """
    Python interface for did_multiplegt_stat.

    Parameters
    ----------
    df : DataFrame - Panel data in long format.
    Y, ID, Time, D : str - Column names for outcome, unit ID, time, treatment.
    Z : str, optional - Instrument variable for IV-WAS.
    estimator : str or list - 'as', 'was', 'iv-was'.
    as_vs_was : bool - Test equality of the AS and WAS estimators.
    order : int or list of 1/4/8 ints - Polynomial order(s). 8 ints for IV: first 4=first-stage, last 4=reduced-form.
    placebo : int - Number of placebos (0 = none).
    iv_method : str - IV regression package: 'manual' (default, two OLS), 'linearmodels', or 'econtools'.
    controls : list of str - Control variables.
    cross_fitting : int - Number of cross-fitting folds (0 = none).
    trimming : float - Propensity score trimming threshold (0 = none).
    on_placebo_sample : bool - Estimate only on stayer sample.
    bootstrap : int - Number of bootstrap replications (0 = none).
    twfe : bool or dict - Compare with TWFE regression. Dict keys: same_sample, percentile.
    cross_validation : dict - CV options (algorithm, tolerance, max_k, seed, kfolds).
    by_baseline : int - Number of quantile bins for baseline treatment.
    cf_folds_file : str, optional - Path to CSV with cross-fitting fold IDs exported by Stata.
        Columns: pairwise, placebo_index, estimator_type, ID_XX, cf_sample_id.
        When provided, fold assignments are read from this file instead of generated internally.
    asinstata : bool, default False - If True, use Stata-faithful regressions
        (custom Newton-Raphson logit + statsmodels OLS).  If False (default),
        use scikit-learn LinearRegression / LogisticRegression for all OLS and
        logit estimations.  Note: changing this flag changes numerical results;
        Stata parity tests require asinstata=True.
    model_deltay : object, optional - Custom regression model for E[DeltaY|D1,S=0].
        Must implement .fit(X, y) and .predict(X) (sklearn-style). When provided,
        overrides the default OLS model (regardless of asinstata). Example:
        RandomForestRegressor(n_estimators=100).
    model_stayer : object, optional - Custom classification model for P(stayer|D1).
        Must implement .fit(X, y) and .predict_proba(X) (sklearn-style). When
        provided, overrides the default logit model (regardless of asinstata).
        Example: RandomForestClassifier(n_estimators=100).
    """
    as_vs_was = resolve_legacy_options(
        as_vs_was=as_vs_was,
        legacy_options=legacy_options,
    )

    if switchers is not None and switchers not in ("up", "down"):
        raise ValueError("Switchers must be None, 'up' or 'down'.")

    # Translate the paper/Stata terminology at the public boundary.  The
    # numerical core retains its historical identifiers to minimize risk.
    public_estimator_list, estimator_list = normalize_estimators(
        estimator,
        has_instrument=Z is not None,
    )

    # Parse multi-order
    order_reg = order_logit_bis = order_logit_Plus = order_logit_Minus = None
    order_reg_fs = order_logit_bis_fs = order_logit_Plus_fs = order_logit_Minus_fs = None
    fs_orders = None  # first-stage orders (list of 4) when order has 8 ints
    if isinstance(order, (list, tuple)):
        if len(order) == 8:
            order_reg_fs, order_logit_bis_fs, order_logit_Plus_fs, order_logit_Minus_fs = order[:4]
            order_reg, order_logit_bis, order_logit_Plus, order_logit_Minus = order[4:]
            order_scalar = order[4]
            fs_orders = list(order[:4])
        elif len(order) == 4:
            order_reg, order_logit_bis, order_logit_Plus, order_logit_Minus = order
            order_scalar = order[0]
        elif len(order) == 1:
            order_scalar = order[0]
        else:
            raise ValueError("order must be an integer, or a list of 1, 4, or 8 integers.")
    else:
        order_scalar = int(order)

    controls_list = list(controls) if controls is not None else None
    other_treatments_list = list(other_treatments) if other_treatments is not None else None

    # Parse twfe options
    if isinstance(twfe, dict):
        twfe_active = True
        twfe_same_sample = twfe.get("same_sample", False)
        twfe_percentile = twfe.get("percentile", False)
    else:
        twfe_active = bool(twfe)
        twfe_same_sample = False
        twfe_percentile = False

    # Convert trimming from percentage (Stata convention) to decimal
    if trimming > 1:
        trimming = trimming / 100.0

    # Estimation method is intentionally internal: DR is the package default,
    # while exact matching uses RA as required by the estimator definition.
    if exact_match:
        estimation_method = "ra"
        if noextrapolation:
            noextrapolation = False
        order_scalar = 1
        order_reg = order_logit_bis = order_logit_Plus = order_logit_Minus = None
    else:
        estimation_method = "dr"

    # Validation
    if "ivwaoss" in estimator_list and any(e in ("aoss", "waoss") for e in estimator_list):
        raise ValueError("Cannot combine AS/WAS with IV-WAS.")
    if "ivwaoss" in estimator_list and Z is None:
        raise ValueError("IV variable Z is required for IV-WAS.")
    if by is not None and by_fd is not None:
        raise ValueError("Cannot specify both by and by_fd.")
    if by is not None and by_baseline is not None:
        raise ValueError("Cannot specify both by and by_baseline.")
    if by_fd is not None and by_baseline is not None:
        raise ValueError("Cannot specify both by_fd and by_baseline.")
    if on_placebo_sample and "ivwaoss" in estimator_list:
        raise ValueError("on_placebo_sample not allowed with iv-was.")
    if on_placebo_sample and placebo > 0:
        raise ValueError("on_placebo_sample not allowed with placebo().")
    if bootstrap > 0 and "ivwaoss" not in estimator_list and not twfe_active:
        raise ValueError("Bootstrap is only available for iv-was or combined with twfe.")
    if twfe_active and len(estimator_list) > 1:
        raise ValueError("Only one estimator allowed with twfe.")
    if twfe_active and "aoss" in estimator_list:
        raise ValueError("twfe is only compatible with was and iv-was.")
    if cross_validation is not None and order_scalar > 1:
        print("Warning: order() ignored when cross_validation is specified.")
        order_scalar = 1

    # Store original order for display (could be list or int)
    # For 8-order case: first 4 are first-stage (WAS), last 4 are reduced-form (IV-WAS)
    order_display = order  # Keep original format for display
    if isinstance(order, (list, tuple)) and len(order) == 8:
        # For IV-WAS with 8 orders: store separate orders for display
        order_fs_display = list(order[:4])   # First-stage orders (1,2,3,4)
        order_rf_display = list(order[4:])   # Reduced-form/IV-WAS orders (5,6,7,8)
    else:
        order_fs_display = order_display
        order_rf_display = order_display

    out: dict[str, Any] = {
        "args": {
            "Y": Y, "ID": ID, "Time": Time, "D": D, "Z": Z,
            "estimator": public_estimator_list,
            "_estimation_method": estimation_method,
            "order": order_scalar, "order_original": order_display,
            "order_fs": order_fs_display, "order_rf": order_rf_display,
            "noextrapolation": noextrapolation,
            "placebo": placebo, "switchers": switchers,
            "disaggregate": disaggregate, "as_vs_was": as_vs_was,
            "exact_match": exact_match, "by": list(by) if by else None,
            "by_fd": by_fd, "by_baseline": by_baseline,
            "other_treatments": other_treatments_list,
            "cluster": cluster, "weight": weight,
            "controls": controls_list,
            "cross_fitting": cross_fitting, "trimming": trimming,
            "on_placebo_sample": on_placebo_sample,
            "bootstrap": bootstrap, "twfe": twfe,
            "iv_method": iv_method,
            "asinstata": asinstata,
            "model_deltay": model_deltay,
            "model_stayer": model_stayer,
        }
    }

    df_work = df.copy()
    mode = "_no_by"
    by_levels = ["_no_by"]

    # --- by() ---
    if by is not None:
        by_list = list(by)
        for v in by_list:
            if not by_check(df_work, ID, v):
                raise ValueError(f"by variable {v} must be constant within ID.")
        comp = df_work[by_list].astype(str)
        by_total = comp[by_list[0]]
        for v in by_list[1:]:
            by_total = by_total + "," + comp[v]
        df_work["by_total"] = by_total
        by_levels = sorted(df_work["by_total"].dropna().unique().tolist())
        out["by_levels"] = by_levels
        mode = "by"

    # --- by_fd() ---
    if by_fd is not None:
        q_levels = np.linspace(0, 1, by_fd + 1).tolist()
        by_set = did_multiplegt_stat_quantiles(df=df_work, ID=ID, Time=Time, D=D, Z=Z,
                                               by_opt=by_fd, quantiles=q_levels, by_baseline=False)
        df_work = by_set["df"]
        out["val_quantiles"] = by_set.get("val_quantiles")
        out["switch_df"] = by_set.get("switch_df")
        part = df_work.loc[df_work["partition_XX"].notna() & (df_work["partition_XX"] != 0), "partition_XX"]
        by_levels = sorted(part.astype(int).unique().tolist())
        out["by_levels"] = by_levels
        mode = "by_fd"

    # --- by_baseline() ---
    if by_baseline is not None:
        q_levels = np.linspace(0, 1, by_baseline + 1).tolist()
        by_set = did_multiplegt_stat_quantiles(df=df_work, ID=ID, Time=Time, D=D, Z=Z,
                                               by_opt=by_baseline, quantiles=q_levels, by_baseline=True)
        df_work = by_set["df"]
        out["val_quantiles"] = by_set.get("val_quantiles")
        out["switch_df"] = by_set.get("switch_df")
        # by_baseline uses partition_lead_XX (no shift needed)
        pcol = "partition_lead_XX"
        part = df_work.loc[df_work[pcol].notna() & (df_work[pcol] != 0), pcol]
        by_levels = sorted(part.astype(int).unique().tolist())
        out["by_levels"] = by_levels
        mode = "by_baseline"

    # --- First-stage for IV-WAOSS ---
    if "ivwaoss" in estimator_list:
        fs_order_arg = fs_orders if fs_orders is not None else order
        print("=" * 80)
        print(" " * 30 + "First stage estimation")
        print("=" * 80)
        fs_result = did_multiplegt_stat(
            df, Y=D, ID=ID, Time=Time, D=Z, Z=None,
            estimator="was",
            order=fs_order_arg,
            noextrapolation=noextrapolation,
            placebo=placebo, switchers=switchers,
            exact_match=exact_match,
            other_treatments=other_treatments,
            cluster=cluster, weight=weight,
            controls=controls,
            cross_fitting=cross_fitting,
            trimming=trimming,
            on_placebo_sample=on_placebo_sample,
            cross_validation=cross_validation,
            cf_folds_file=cf_folds_file,
            asinstata=asinstata,
            model_deltay=model_deltay,
            model_stayer=model_stayer,
        )
        out["first_stage"] = fs_result
        print("=" * 80)
        print(" " * 30 + "Reduced form estimation")
        print("=" * 80)

    def _call_main(df_in, by_fd_opt=None):
        return did_multiplegt_stat_main(
            df=df_in, Y=Y, ID=ID, Time=Time, D=D, Z=Z,
            estimator=estimator_list, estimation_method=estimation_method,
            order=order_scalar, noextrapolation=noextrapolation,
            placebo=placebo, switchers=switchers,
            disaggregate=disaggregate, aoss_vs_waoss=as_vs_was,
            exact_match=exact_match, weight=weight, cluster=cluster,
            by_fd_opt=by_fd_opt, other_treatments=other_treatments_list,
            controls=controls_list, cross_fitting=cross_fitting,
            trimming=trimming, on_placebo_sample=on_placebo_sample,
            order_reg=order_reg, order_logit_bis=order_logit_bis,
            order_logit_Plus=order_logit_Plus, order_logit_Minus=order_logit_Minus,
            bootstrap=bootstrap, twfe=twfe_active, seed=seed,
            cross_validation_opt=cross_validation,
            cf_folds_file=cf_folds_file,
            asinstata=asinstata,
            model_deltay=model_deltay,
            model_stayer=model_stayer,
        )

    if mode == "_no_by":
        out["results"] = _call_main(df_work)
    elif mode == "by":
        for j, lev in enumerate(by_levels, start=1):
            df_sub = df_work[df_work["by_total"] == lev].copy()
            print(f"Running did_multiplegt_stat with by = {lev}")
            out[f"results_by_{j}"] = _call_main(df_sub)
    elif mode in ("by_fd", "by_baseline"):
        for j, lev in enumerate(by_levels, start=1):
            print(f"Running did_multiplegt_stat for bin {lev}")
            out[f"results_by_{j}"] = _call_main(df_work, by_fd_opt=int(lev))

    # --- Bootstrap ---
    if bootstrap > 0:
        _run_bootstrap(out, df_work, Y, ID, Time, D, Z, estimator_list,
                       estimation_method, order_scalar, noextrapolation, placebo,
                       switchers, exact_match, weight, cluster,
                       other_treatments_list, controls_list,
                       cross_fitting, trimming, on_placebo_sample,
                       order_reg, order_logit_bis, order_logit_Plus, order_logit_Minus,
                       bootstrap, twfe_active, seed, cross_validation,
                       twfe_same_sample=twfe_same_sample,
                       twfe_percentile=twfe_percentile,
                       iv_method=iv_method,
                       asinstata=asinstata,
                       model_deltay=model_deltay,
                       model_stayer=model_stayer)

    out["_class"] = "did_multiplegt_stat"
    return out

summary_did_multiplegt_stat

summary_did_multiplegt_stat

summary_did_multiplegt_stat(obj: dict[str, Any])
Source code in src/did_multiplegt_stat/core.py
def summary_did_multiplegt_stat(obj: dict[str, Any]):
    args = obj.get("args", {})
    _, estim_list = normalize_estimators(
        args.get("estimator", ["as", "was"]),
        has_instrument=args.get("Z") is not None,
        warn_legacy=False,
    )
    by_var = args.get("by")
    by_fd = args.get("by_fd")
    by_baseline = args.get("by_baseline")

    if by_var is None and by_fd is None and by_baseline is None:
        by_levs = ["_no_by"]
        by_obj = ["results"]
    else:
        by_levs = list(obj.get("by_levels", []))
        by_obj = [f"results_by_{j + 1}" for j in range(len(by_levs))]

    estims_map = ESTIMATOR_POSITIONS
    # Display names matching Stata
    estim_titles = {
        "aoss": "Average Slope (AS)",
        "waoss": "Weighted Average Slope (WAS)",
        "ivwaoss": "IV-Weighted Average Slope (IV-WAS)"
    }
    placebo_titles = {
        "aoss": "Placebo(s) AS",
        "waoss": "Placebo(s) WAS",
        "ivwaoss": "Placebo(s) IV-WAS"
    }

    for idx, key in enumerate(by_obj):
        print_obj = obj.get(key, None)
        if print_obj is None:
            continue

        if by_levs[idx] != "_no_by":
            print(f"\n{'#' * 70}")
            print(f" By level: {by_levs[idx]}")

        # Stata-style header with 46-char dashes
        print(f"{' ' * 34}{'-' * 46}")
        table = print_obj.get("table", None)
        pairs = int(print_obj.get("pairs", 1))

        N = print_obj.get("N", np.nan)
        print(f"{' ' * 35}Number of observations{' ' * 5}={' ' * (17 - len(str(int(N))))}{int(N)}")

        methods = {"ra": "reg. adjustment", "dr": "doubly-robust", "ps": "propensity-score"}
        method = args.get("_estimation_method", "ra" if args.get("exact_match") else "dr")

        # Show estimation method - Stata shows different label for IV-WAS
        if "ivwaoss" in estim_list:
            print(f"{' ' * 35}IV-WAS Estimation method{' ' * 5}={' ' * 4}{methods.get(method, method)}")
            # Show IV regression package used
            iv_method = args.get("iv_method", "manual")
            iv_pkg_names = {"manual": "manual 2SLS", "linearmodels": "linearmodels.IV2SLS", "econtools": "econtools.ivreg"}
            print(f"{' ' * 35}IV regression package{' ' * 8}={' ' * 4}{iv_pkg_names.get(iv_method, iv_method)}")
        elif "waoss" in estim_list or "aoss" in estim_list:
            print(f"{' ' * 35}Estimation method{' ' * 10}={' ' * 4}{methods.get(method, method)}")

        # Polynomial order with parentheses like Stata
        # For IV-WAS: use order_rf (reduced-form orders, e.g., 5,6,7,8)
        # For WAS/AS: use order_fs or order_original
        if not args.get("exact_match") and args.get("order") is not None:
            if "ivwaoss" in estim_list:
                # Use reduced-form orders for IV-WAS
                order_val = args.get("order_rf", args.get("order_original", args.get("order")))
            else:
                # Use first-stage orders for WAS/AS
                order_val = args.get("order_fs", args.get("order_original", args.get("order")))
            if isinstance(order_val, (list, tuple)):
                order_str = "(" + " ".join(str(x) for x in order_val) + ")"
            else:
                order_str = f"({order_val})"
            print(f"{' ' * 34}Polynomial order{' ' * 11}={' ' * (17 - len(order_str))}{order_str}")

        if args.get("exact_match"):
            print(f"{' ' * 34}Common support{' ' * 12}={' ' * 2}exact matching")
        if args.get("noextrapolation"):
            print(f"{' ' * 34}Common support{' ' * 13}= no extrapolation")
        if args.get("switchers"):
            sw = args.get("switchers")
            print(f"{' ' * 34}Switchers{' ' * 17}={' ' * (17 - len(sw))}{sw}")

        print(f"{' ' * 34}{'-' * 46}")

        n_clusters = print_obj.get("n_clusters", None)
        if n_clusters is not None:
            cluster_name = args.get('cluster')
            print(f"(Std. err. adjusted for {n_clusters} clusters in {cluster_name})")

        for t in ("aoss", "waoss", "ivwaoss"):
            if t not in estim_list:
                continue
            # Stata-style section title
            print(f"{'-' * 80}")
            title = estim_titles[t]
            padding = (80 - len(title)) // 2
            print(f"{' ' * padding}{title}")
            print(f"{'-' * 80}")

            if isinstance(table, pd.DataFrame):
                l_bound = estims_map[t] * pairs
                u_bound = l_bound + (pairs if args.get("disaggregate") else 1)
                mat_sel = table.iloc[l_bound:u_bound]
                mat_print(mat_sel)

            # Placebo tables — grouped by estimator (matching Stata)
            placebo_n = args.get("placebo", 0)
            if placebo_n > 0:
                estim_idx = estims_map[t]  # 0=aoss, 1=waoss, 2=ivwaoss
                pl_rows = []
                for pl_idx in range(1, placebo_n + 1):
                    table_p = print_obj.get(f"table_placebo_{pl_idx}", print_obj.get("table_placebo", None))
                    if isinstance(table_p, pd.DataFrame) and estim_idx < len(table_p):
                        row = table_p.iloc[[estim_idx]].copy()
                        # Rename to Placebo_N - Stata format
                        row.index = [f"Placebo_{pl_idx}"]
                        # Skip if not computed (NaN estimate and 0 switchers)
                        if not (np.isnan(row.iloc[0]["Estimate"]) and row.iloc[0]["Switchers"] == 0):
                            pl_rows.append(row)
                if pl_rows:
                    pl_combined = pd.concat(pl_rows)
                    # Stata-style placebo section title
                    print(f"{'-' * 80}")
                    pl_title = placebo_titles[t]
                    pl_padding = (80 - len(pl_title)) // 2
                    print(f"{' ' * pl_padding}{pl_title}")
                    print(f"{'-' * 80}")
                    mat_print(pl_combined)

        if args.get("as_vs_was"):
            diff_tab = print_obj.get("as_vs_was", None)
            if diff_tab is not None:
                print(" ")
                print(f"{'-' * 80}")
                # Stata: "Test of difference between AS and WAS"
                title = "Test of difference between AS and WAS"
                padding = (80 - len(title)) // 2
                print(f"{' ' * padding}{title}")
                print("H0: AS = WAS")
                print(f"{'-' * 80}")
                tab_print(diff_tab)

        # Final closing line
        print(f"{'-' * 80}")

    # First-stage results (IV-WAOSS)
    fs_obj = obj.get("first_stage", None)
    if fs_obj is not None:
        print(f"\n{'=' * 80}")
        print(f"{' ' * 25}First stage estimation")
        print(f"{'=' * 80}")
        summary_did_multiplegt_stat(fs_obj)
        print(f"{'=' * 80}")
        print(f"{' ' * 25}Reduced form estimation (above)")
        print(f"{'=' * 80}")

    # TWFE comparison - Stata format
    twfe_tab = obj.get("twfe_comparison", None)
    if twfe_tab is not None:
        print(" ")
        # Determine estimator label for title
        if "ivwaoss" in estim_list:
            title = "Test of difference between TWFE and IV-WAS"
        elif "waoss" in estim_list:
            title = "Test of difference between TWFE and WAS"
        else:
            title = "Test of difference between TWFE and AS"
        padding = (80 - len(title)) // 2
        print(f"{' ' * padding}{title}")
        if "ivwaoss" in estim_list:
            print("H0: TWFE = IV-WAS")
        elif "waoss" in estim_list:
            print("H0: TWFE = WAS")
        else:
            print("H0: TWFE = AS")
        print(f"{'-' * 80}")
        tab_print(twfe_tab)
        print(f"{'-' * 80}")
        print("Values in Column Estimate. are means of bootstrap's point estimates.")

print_did_multiplegt_stat

print_did_multiplegt_stat(obj: dict[str, Any])
Source code in src/did_multiplegt_stat/core.py
def print_did_multiplegt_stat(obj: dict[str, Any]):
    summary_did_multiplegt_stat(obj)

Return value (dict)

The returned dict has the following keys (omitting placebo / by-group blocks when not requested):

Key Type Description
args dict A snapshot of every option passed to did_multiplegt_stat.
results dict The single main-results block when no by/by_fd/by_baseline.
results_by_{j} dict Per-by-group results block when by/by_fd/by_baseline is set. j runs from 1.
by_levels list Levels in order, so by_levels[j-1] matches results_by_{j}.
first_stage dict Same shape as the top-level dict, returned by the inner first-stage call when estimator="iv-was".
twfe_comparison pd.DataFrame Bootstrap-based TWFE comparison table when twfe=True.
val_quantiles list Quantile cut-points when by_fd / by_baseline is set.
switch_df pd.DataFrame Per-bin switcher count + median |ΔD| when by_fd.
_class str Always "did_multiplegt_stat". Useful sentinel for type checks.

Inside each results* block:

Key Type Description
table pd.DataFrame Main effects table for this block.
table_placebo_{p} pd.DataFrame Placebo table for placebo p ∈ {1, …, N}.
N int Number of observations used in this block.
n_clusters int Set only when cluster= was used.
pairs int Number of consecutive-period pairs in the panel.
as_vs_was pd.DataFrame Difference-test table when as_vs_was=True.