본문으로 건너뛰기
라이브러리 문서 전체

ETF 모멘텀의 이중 머신러닝 인과 효과

코드 Machine Learning for Trading

요약

이 노트북은 이중 머신러닝(DML)을 사용해 연속형 ETF 모멘텀 지표가 이후 수익률에 미치는 조정 효과를 추정합니다. 최근 및 장기 변동성, 시장 국면, 수익률 곡선 기울기를 통제합니다. 조정하지 않은 회귀를 EconML 및 직접 구현한 DML와 비교하고, 교란 보정용 모델 선택이 추정치에 미치는 영향을 논의합니다. 인과적 해석은 충분한 처치 전 통제 변수와 다른 식별 가정에 달려 있으며, 관측되지 않은 충격과 모델 오지정은 여전히 편향의 원인이 될 수 있습니다.

핵심은 패널 보정이 펼쳐진 ETF-날짜 행이 아니라 의사결정 날짜를 따라야 한다는 점입니다. 워크포워드 폴드는 고유 날짜를 기준으로 만들고, 드리스콜-크레이 표준 오차는 시계열 조정을 적용하기 전에 날짜별로 집계하며, 플라시보 블록은 각 ETF 안에서 순열 처리합니다. 노트북은 추정치 비교, 표준 오차 증가, 순열 결과를 보고하지만 발췌에는 구체적인 수치 결과가 없습니다. 이 방법은 패널 누수와 의존성을 다루지만, 명시된 가정 없이 관측된 연관성을 인과 근거로 바꾸지는 못합니다.

핵심 아이디어

  • DML는 지정된 처치 전 통제 변수로 모멘텀-수익률 추정치를 조정하지만, 인과적 해석에는 강한 식별 가정이 필요합니다.
  • 패널 교차 검증과 추론에서는 각 ETF-날짜 행을 연속된 시점으로 취급하지 말고 의사결정 날짜별로 관측치를 묶어야 합니다.
  • 드리스콜-크레이 표준 오차는 시간 의존성을 반영하기 전에 의사결정 시점별 회귀 점수를 집계합니다.
  • 블록 순열은 각 개체의 자체 거래 이력에 나타나는 지속성을 보존해야 합니다.
  • 조정 추정치는 방해 변수 모델의 유연성에 따라 달라질 수 있으며, 직교성이 모델 선택에 따른 결과의 일치를 보장하지는 않습니다.

태그

전문
# 03_econml_dml.py


```py
# ---
# jupyter:
#   jupytext:
#     cell_metadata_filter: tags,-all
#     text_representation:
#       extension: .py
#       format_name: percent
#       format_version: '1.3'
#       jupytext_version: 1.19.3
#   kernelspec:
#     display_name: Python 3 (ipykernel)
#     language: python
#     name: python3
# ---

# %% [markdown]
# # Double Machine Learning for Momentum Causal Effect
#
# **Chapter 15: Causal Estimation**
# **Docker image**: `ml4t`
# **Section Reference**: See Section 15.4 for DML theory and the ETF factor application
#
# ## Purpose
# **Double machine learning** (DML) estimates the effect of a momentum signal on forward
# returns while adjusting for volatility and regime, on a panel of ETFs. The estimate is one
# of two things this notebook is about. The other is that a panel breaks every correction
# that has a notion of "nearby": the standard error, the cross-validation folds, the
# permutation blocks and the temporal subsets all count in rows unless they are told not to,
# and on a stacked panel a row is a different ETF rather than the next day.
#
# ## Learning Objectives
# - LO1: Explain what confounding does to a factor regression, and what adjustment can and
#   cannot recover
# - LO2: Fit a DML estimator with EconML for a continuous treatment
# - LO3: Compare the raw and adjusted estimates and read the direction of the bias
# - LO4: Build folds, standard errors and permutations that count in decision times
# - LO5: Read a refutation test, including one that cannot refute anything
#
# ## Cross-References
# - **Upstream**: Chapter 8 (ETF momentum features)
# - **Downstream**: Chapter 16 (strategy simulation), Chapter 19 (risk management)
# - **Related**: [`02_dowhy_causal_graph`](02_dowhy_causal_graph.ipynb) (graphical approach), [`04_dml_crypto_regime`](04_dml_crypto_regime.ipynb) (regime effects)
#
# ## Methodological Notes
# Following Chernozhukov et al. (2017) and de Prado (2018):
# - **WalkForwardCV** from ml4t-diagnostics, with purging and embargo, built over the
#   panel's decision times
# - **Driscoll-Kraay standard errors**, which aggregate by decision time before applying the
#   Newey-West kernel
# - **Block permutation within entity**, so the placebo treatment keeps its persistence
#
# **Prerequisites**: [`02_dowhy_causal_graph`](02_dowhy_causal_graph.ipynb) for the DoWhy
# workflow, and an ETF modeling dataset built by the features pipeline
#
# ## Causal Design Contract
#
# | Element                   | This notebook                                                                                |
# |---------------------------|----------------------------------------------------------------------------------------------|
# | Unit                      | ETF-date row from the ETF modeling panel                                                     |
# | Treatment                 | `skip_recent_6_1` - 6/1 momentum factor (continuous)                                          |
# | Outcome                   | `fwd_ret_21d` - 21-day forward return                                                         |
# | Controls (W in EconML)    | `vol_21d`, `vol_126d`, `regime`, `yield_curve_slope` - all backward-looking, pre-treatment    |
# | Effect modifiers (X)      | None in this notebook; constant ATE target. See `04_dml_crypto_regime` for the X-slot example |
# | Identification assumption | Selection on observables given the four controls; sufficient pre-treatment information       |
# | Main failure mode         | Unobserved confounding (sentiment / macro shocks), nuisance-model misspecification, panel-time leakage if CV is not date-grouped |
# | Estimand                  | Marginal effect of a one-unit change in 6/1 momentum on 21-day forward return after adjustment |

# %% [markdown]
# ## Setup

# %%
"""Double Machine Learning for Momentum Causal Effect - estimate causal effect of momentum on forward returns."""

import warnings

import numpy as np
import pandas as pd
from ml4t.diagnostic.splitters import WalkForwardCV
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.linear_model import Ridge

from case_studies.utils.causal import (
    block_permute,
    empirical_permutation_p,
    manual_dml_timeseries,
)
from utils.modeling import load_modeling_dataset
from utils.reproducibility import set_global_seeds

# scikit-learn repeats a notice, once per nuisance fit, that a frame carrying feature names
# was fitted and a bare array predicted; EconML does that internally. Convergence and
# numerical warnings stay visible.
warnings.filterwarnings("ignore", category=UserWarning, module="sklearn.utils.validation")

# Statsmodels for HAC standard errors
import statsmodels.api as sm
from econml.dml import LinearDML
from statsmodels.regression.linear_model import OLS

# %% [markdown]
# ## Configuration

# %% tags=["parameters"]
# Configuration - readers can modify these
CASE_STUDY_ID = "etfs"
PRIMARY_LABEL = "fwd_ret_21d"
MAX_SYMBOLS = 0
CV_FOLDS = 5
MAX_SAMPLES = 50000  # Temporal subsample if dataset too large
SEED = 42

# Cross-validation parameters for WalkForwardCV
FORWARD_HORIZON = 21  # 21-day forward returns
LABEL_HORIZON = FORWARD_HORIZON  # Purge overlapping samples
EMBARGO_PCT = 0.01  # 1% embargo after test set

# Refutation test parameters
N_PLACEBO_PERMUTATIONS = 100
BLOCK_SIZE = 21  # Block size for permutation (match forward horizon)

# %%
set_global_seeds(SEED)
print(f"Seed: {SEED}")

# %% [markdown]
# ## 1. Load ETF Features from Modeling Pipeline
#
# We use `load_modeling_dataset()` to load pre-computed features (Ch8),
# temporal features (Ch9), and labels, joined and ready for analysis.
# Real-data only - no synthetic fallback. If the modeling dataset is missing,
# the notebook fails loudly with a clear error rather than silently switching
# to a synthetic substitute that would publish indistinguishable numbers.

# %%
# A load failure is fatal rather than a fallback to synthetic data.
mds = load_modeling_dataset(CASE_STUDY_ID, PRIMARY_LABEL, max_symbols=MAX_SYMBOLS)

treatment_col = "skip_recent_6_1"
outcome_col = mds.label_col
confounder_cols = ["vol_21d", "vol_126d", "regime", "yield_curve_slope"]
entity_col = mds.entity_cols[0]

available = set(mds.dataset.columns)
missing = [c for c in [treatment_col, outcome_col] + confounder_cols if c not in available]
if missing:
    raise RuntimeError(
        f"Required columns missing from modeling dataset "
        f"{CASE_STUDY_ID}/{PRIMARY_LABEL}: {missing}. "
        f"Available features: {mds.feature_names[:20]}... "
        f"Set ML4T_DATA_PATH and rebuild the Ch8 features pipeline for case "
        f"study '{CASE_STUDY_ID}'."
    )

# Convert to pandas for sklearn/econml, sorted by date
analysis_cols = [mds.date_col] + mds.entity_cols + [treatment_col, outcome_col] + confounder_cols
df = (
    mds.dataset.select([c for c in analysis_cols if c in available])
    .drop_nulls()
    .sort(mds.date_col)
    .to_pandas()
)
# Temporal subsample if too large: take the most recent N unique dates so the
# subsample never cuts through a cross-section. `df.iloc[-MAX_SAMPLES:]` would
# slice at row level on a stacked panel and leave a fragmented final date.
if len(df) > MAX_SAMPLES:
    rows_per_date = df.groupby(mds.date_col).size().median()
    n_dates = int(np.ceil(MAX_SAMPLES / max(rows_per_date, 1)))
    keep_dates = df[mds.date_col].drop_duplicates().iloc[-n_dates:]
    df = df[df[mds.date_col].isin(keep_dates)].reset_index(drop=True)
    print(f"Taking most recent {n_dates} dates ({len(df):,} rows) from {len(keep_dates):,} dates")

print(f"Analysis data: {df.shape[0]:,} rows × {df.shape[1]} columns")
print(f"Date range: {df[mds.date_col].min()} to {df[mds.date_col].max()}")
print(f"Treatment: {treatment_col}, Outcome: {outcome_col}")

# %% [markdown]
# ### The Panel Keys, and Why Everything Below Needs Them
#
# A row here is an ETF *and* a date, and the frame is sorted by date, so consecutive rows
# are usually different ETFs on the same day rather than the same ETF on consecutive days.
# Every correction in this notebook that has a notion of "nearby" - the standard error, the
# cross-validation folds, the permutation blocks, the temporal subsets - has to count in
# decision times, not in rows. Counting in rows on this frame measures a slice of one day's
# cross-section and calls it three weeks.
#
# The two arrays below carry that information, and each of those four places takes them.

# %%
decision_times = df[mds.date_col].to_numpy()
entities = df[entity_col].to_numpy()

unique_dates = np.sort(pd.unique(decision_times))
date_position = pd.Series(np.arange(len(unique_dates)), index=unique_dates)
row_date_position = date_position.reindex(decision_times).to_numpy()

print(
    f"{len(df):,} rows over {len(unique_dates):,} decision times "
    f"and {df[entity_col].nunique()} entities "
    f"({len(df) / len(unique_dates):.0f} rows per decision time)"
)


def panel_folds(n_splits):
    """Walk-forward folds built over decision times, then expanded to panel rows.

    WalkForwardCV counts `label_horizon` and the embargo in the positions it is handed. Fed
    the panel's rows it would purge a fraction of one date; fed the ordered unique dates it
    purges the 21 trading days the forward return actually spans. The row indices come back
    by membership, so no fold boundary cuts through a cross-section.
    """
    splitter = WalkForwardCV(
        n_splits=n_splits,
        label_horizon=LABEL_HORIZON,
        embargo_pct=EMBARGO_PCT,
        expanding=True,
    )
    folds = []
    for train_dates, test_dates in splitter.split(np.arange(len(unique_dates)).reshape(-1, 1)):
        folds.append(
            (
                np.flatnonzero(np.isin(row_date_position, train_dates)),
                np.flatnonzero(np.isin(row_date_position, test_dates)),
            )
        )
    return folds


# %% [markdown]
# ## 2. The Unadjusted Slope
#
# An OLS regression of forward returns on momentum, with nothing else in it. It is the
# benchmark the adjusted estimates are read against, and it is confounded by construction:
# volatility and the yield-curve regime both move the momentum signal and move forward
# returns, so its slope carries their contribution as well as momentum's. Which way that
# pushes the slope is an empirical question the comparison below answers.

# %% [markdown]
# ### Which Robust Standard Error a Panel Takes
#
# Overlapping 21-day returns make consecutive observations of one ETF correlated, and the
# usual answer is a Newey-West standard error with the bandwidth set to the label horizon.
# On this frame that answer is applied to the wrong axis. `cov_type="HAC"` runs its kernel
# down the rows, and 21 rows here are a fifth of one day's cross-section, so the correction
# treats different ETFs on the same day as if they were successive days.
#
# **Driscoll-Kraay** is the version that fits a panel. It aggregates the regression score by
# decision time first and applies the Newey-West kernel to that time series, which makes it
# robust both to the serial correlation the overlap creates and to whatever the ETFs share
# on a given day. statsmodels reaches it through `cov_type="hac-groupsum"` with a `time`
# argument, which is the same call `case_studies/utils/causal.py` makes for every case study.

# %%
y = df[outcome_col].values
X_with_const = sm.add_constant(df[[treatment_col]])

ols_iid = OLS(y, X_with_const).fit()
naive_estimate = float(ols_iid.params.iloc[1])
se_iid = float(ols_iid.bse.iloc[1])

# Driscoll-Kraay: the kernel runs over decision times, with the bandwidth at the label
# horizon because a 21-day forward return overlaps for 20 of every 21 days.
HAC_LAGS = FORWARD_HORIZON
time_codes = pd.factorize(decision_times, sort=False)[0]
ols_dk = ols_iid.get_robustcov_results(
    cov_type="hac-groupsum",
    time=time_codes,
    maxlags=HAC_LAGS,
    use_correction="hac",
    df_correction=False,
)
se_hac = float(np.sqrt(np.asarray(ols_dk.cov_params())[1, 1]))
t_stat_hac = naive_estimate / se_hac

naive_ci = (naive_estimate - 1.96 * se_hac, naive_estimate + 1.96 * se_hac)

print("=" * 60)
print("NAIVE ESTIMATE (ignoring confounders)")
print("=" * 60)
print(f"Coefficient: {naive_estimate:.6f}")
print(f"Standard Error (IID): {se_iid:.6f}")
print(f"Standard Error (Driscoll-Kraay): {se_hac:.6f}")
print(f"SE Inflation (DK/IID): {se_hac / se_iid:.2f}x")
print(f"95% CI (Driscoll-Kraay): [{naive_ci[0]:.6f}, {naive_ci[1]:.6f}]")
print(f"t-statistic (Driscoll-Kraay): {t_stat_hac:.2f}")

# %% [markdown]
# ## 3. Double Machine Learning Estimate under Observed-Confounder Adjustment
#
# DML uses a three-step orthogonalization process:
#
# 1. **Predict outcome from confounders**: $\hat{Y} = g(X)$ → residual $\tilde{Y} = Y - \hat{Y}$
# 2. **Predict treatment from confounders**: $\hat{T} = m(X)$ → residual $\tilde{T} = T - \hat{T}$
# 3. **Regress residual outcome on residual treatment**: $\tilde{Y} \sim \theta \tilde{T}$
#
# The coefficient $\theta$ is an *orthogonalized* estimate of the treatment
# effect under the maintained assumption that the specified controls capture
# the relevant pre-treatment confounding variation. DML reduces sensitivity
# to nuisance-model errors but does not, by itself, solve unobserved
# confounding, simultaneity, interference, or bad-control bias.
#
# The folds come from `WalkForwardCV` rather than `KFold` or `TimeSeriesSplit`, because
# cross-fitting a forward-looking label needs two things neither of those provides. **Purging**
# drops training rows whose 21-day label window overlaps the test window; the **embargo**
# leaves a gap after the test window so autocorrelation does not carry back into the next
# training set (Chernozhukov et al. 2017, de Prado 2018).

# %% [markdown]
# The confounders go into EconML's `W` slot, which holds controls used for residualization.
# The `X` slot is for effect modifiers, the variables along which the treatment effect is
# allowed to vary. Putting plain confounders in `X` still produces a single ATE, so nothing
# visibly breaks, but it teaches the wrong habit for `04_dml_crypto_regime`, where the two
# slots carry different variables and the distinction decides what the model estimates.

# %%
Y = df[outcome_col].to_numpy()
T = df[treatment_col].to_numpy()
W = df[confounder_cols].to_numpy()  # controls used for residualization

# Walk-forward folds over decision times, expanded to rows (see `panel_folds` above).
cv = panel_folds(CV_FOLDS)
print(
    f"Using WalkForwardCV over decision times (label_horizon={LABEL_HORIZON} trading days, "
    f"embargo={EMBARGO_PCT:.1%})"
)
for i, (train_idx, test_idx) in enumerate(cv):
    print(f"  fold {i}: train {len(train_idx):,} rows, test {len(test_idx):,} rows")

dml = LinearDML(
    model_y=GradientBoostingRegressor(n_estimators=50, max_depth=3, random_state=SEED),
    model_t=GradientBoostingRegressor(n_estimators=50, max_depth=3, random_state=SEED),
    cv=cv,
    random_state=SEED,
)

dml.fit(Y, T, W=W)

dml_estimate = float(dml.ate())
dml_ci_lower, dml_ci_upper = (float(v) for v in dml.ate_interval(alpha=0.05))
bias = naive_estimate - dml_estimate
bias_pct = 100 * bias / abs(dml_estimate) if dml_estimate != 0 else float("inf")

# %%
print("\n" + "=" * 60)
print("DOUBLE ML ESTIMATE (controlling for confounders)")
print("=" * 60)
print(f"Average Treatment Effect: {dml_estimate:.6f}")
if not np.isnan(dml_ci_lower):
    print(f"95% CI: [{dml_ci_lower:.6f}, {dml_ci_upper:.6f}]")
else:
    print(
        "95% CI: unavailable (EconML inference returned NaN - "
        "likely numerically degenerate first-stage residualization)"
    )

print("\n" + "=" * 60)
print("COMPARISON: Naive vs DML")
print("=" * 60)
print(f"Naive estimate: {naive_estimate:.6f}")
print(f"DML estimate:   {dml_estimate:.6f}")
print(f"Bias (Naive - DML): {bias:.6f}")
print(f"Bias percentage: {bias_pct:.1f}%")

if abs(naive_estimate) > abs(dml_estimate):
    print("\n-> The adjusted effect is smaller: the controls were inflating the raw slope")
else:
    print("\n-> The adjusted effect is larger: the controls were masking part of the slope")

# %% [markdown]
# **Interpretation**: The naive OLS estimate captures both the causal momentum effect and
# spurious correlation induced by shared confounders (volatility, regime). DML's
# orthogonalization removes this confounding by residualizing both treatment and outcome
# against the confounders before estimating the final coefficient.
#
# The direction of bias reveals the confounding structure. If the naive effect is
# smaller in magnitude than the DML estimate, confounders *mask* the true effect -
# for example, high volatility reduces both momentum and returns simultaneously.
# If the naive effect is larger, confounders *inflate* the apparent predictive power.
#
# **What it changes downstream**: a position size derived from the raw slope inherits
# whatever the confounders contributed to it. The adjusted estimate is the one to size on
# when volatility and regime are not separately hedged, and the gap between the two is how
# much the raw slope was carrying.

# %% [markdown]
# ## 4. The Same Estimate, Assembled by Hand
#
# `manual_dml_timeseries` from `case_studies/utils/causal.py` runs the three steps in the
# open: cross-fit the two nuisance models over walk-forward folds with an embargo, take the
# residuals, regress one on the other. Handed `groups`, it builds those folds over decision
# times and reports a Driscoll-Kraay standard error; without them it would do both by row.

# %%
# Use shared manual DML implementation
dml_result = manual_dml_timeseries(
    df[outcome_col].values,
    df[treatment_col].values,
    df[confounder_cols].values,
    n_folds=CV_FOLDS,
    embargo=LABEL_HORIZON,
    groups=decision_times,
    horizon=FORWARD_HORIZON,
)

manual_ate = dml_result["theta"]
manual_se_iid = dml_result["se_iid"]
manual_se_hac = dml_result["se_hac"]
manual_t_hac = dml_result["t_stat_hac"]
manual_ci = (manual_ate - 1.96 * manual_se_hac, manual_ate + 1.96 * manual_se_hac)

print("\n" + "=" * 60)
print("MANUAL DML WITH WALK-FORWARD CV + EMBARGO")
print("=" * 60)
print(f"ATE estimate: {manual_ate:.6f}")
print(f"Standard Error (IID): {manual_se_iid:.6f}")
print(f"Standard Error (HAC): {manual_se_hac:.6f}")
print(f"SE Inflation (HAC/IID): {manual_se_hac / manual_se_iid:.2f}x")
print(f"95% CI (HAC): [{manual_ci[0]:.6f}, {manual_ci[1]:.6f}]")
print(f"t-statistic (Driscoll-Kraay): {manual_t_hac:.2f}")

# %% [markdown]
# ## 5. Refutation Tests
#
# **Critical for causal validity**: We validate the DML estimate using refutation tests.
#
# 1. **Temporal Placebo**: Regress Y on *lead* of T (should be ~0 if no reverse causality)
# 2. **Block Permutation**: Shuffle treatment in blocks to preserve autocorrelation
# 3. **Subset Stability**: Check if effect is stable across temporal subsets

# %%
print("\n" + "=" * 60)
print("REFUTATION TESTS")
print("=" * 60)

# The lead is taken within symbol; a row-level shift would mix ETFs at the boundary.
print("\n1. TEMPORAL PLACEBO TEST (lead of treatment)")
df_placebo = df.sort_values([entity_col, mds.date_col]).copy()
df_placebo["treatment_lead"] = df_placebo.groupby(entity_col)[treatment_col].shift(-FORWARD_HORIZON)
# Back to date order before the fit. The shift needed entity-major rows; the folds need
# every row of a decision time adjacent, and `manual_dml_timeseries` rejects groups that
# are not sorted and contiguous rather than silently splitting one date across two folds.
df_placebo = (
    df_placebo.dropna(subset=["treatment_lead", outcome_col])
    .sort_values(mds.date_col, kind="stable")
    .reset_index(drop=True)
)

if len(df_placebo) > 100:
    placebo_result = manual_dml_timeseries(
        df_placebo[outcome_col].values,
        df_placebo["treatment_lead"].values,
        df_placebo[confounder_cols].values,
        n_folds=CV_FOLDS,
        embargo=LABEL_HORIZON,
        groups=df_placebo[mds.date_col].to_numpy(),
        horizon=FORWARD_HORIZON,
    )
    placebo_effect = placebo_result["theta"]

    print(f"   Lead treatment effect (DML): {placebo_effect:.6f}")
    print(f"   Original DML effect:         {manual_ate:.6f}")
    print(f"   Ratio |lead / original|:     {abs(placebo_effect) / abs(manual_ate):.3f}")
else:
    placebo_effect = None
    print("   Insufficient data for placebo test")

# %% [markdown]
# **Reading the temporal placebo.** A ratio near one is what this test returns here, and it
# is not evidence of reverse causality. The treatment is a momentum signal that barely moves
# over 21 trading days, so the horizon-shifted placebo is nearly the same variable as the
# real treatment and reproduces a similar effect. For a treatment this persistent, the
# shifted-signal placebo cannot refute anything.
#
# The block permutation below is the refuter that can, because it breaks the alignment
# between treatment and outcome while keeping the treatment's own persistence intact - but
# only if the blocks are built along time.

# %% [markdown]
# ### Blocks Along Time, Not Along Rows
#
# `block_permute` given a bare array counts `BLOCK_SIZE` in the positions it is handed. On
# this frame that is about a fifth of one day's cross-section, so a "block" is a handful of
# ETFs on a single day. Permuting those destroys the serial dependence the test is supposed
# to preserve, which is the iid shuffle `block_permute`'s own docstring warns makes placebo
# tests too easy to pass.
#
# Passing `groups` and `units` makes it permute within each ETF, where `BLOCK_SIZE` counts
# that ETF's own ordered trading days and a block is the three weeks the name promises.
#
# Each placebo also runs the estimator being tested, with the same fold count and embargo. A
# placebo fitted on three folds against an estimate fitted on five is a different estimator
# on a different number of cross-fitted rows, and the null would then be centred wherever
# that difference puts it rather than where the absence of an effect does. The observed
# statistic itself moves between the two settings.

# %%
# Test 2: Block Permutation Test (uses shared block_permute)
print(f"\n2. BLOCK PERMUTATION TEST ({N_PLACEBO_PERMUTATIONS} permutations)")
placebo_effects = []
placebo_t_stats = []
permutation_failures = 0
T_original = df[treatment_col].values
rng = np.random.default_rng(SEED)

for i in range(N_PLACEBO_PERMUTATIONS):
    T_permuted = block_permute(
        T_original,
        BLOCK_SIZE,
        rng=rng,
        groups=decision_times,
        units=entities,
    )

    df_perm = df.copy()
    df_perm[treatment_col] = T_permuted

    try:
        perm_result = manual_dml_timeseries(
            df_perm[outcome_col].values,
            df_perm[treatment_col].values,
            df_perm[confounder_cols].values,
            n_folds=CV_FOLDS,  # the estimate's own setting; see the markdown above
            embargo=LABEL_HORIZON,
            groups=decision_times,
            horizon=FORWARD_HORIZON,
        )
        if np.isfinite(perm_result["t_stat_hac"]):
            placebo_t_stats.append(perm_result["t_stat_hac"])
            placebo_effects.append(perm_result["theta"])
        else:
            permutation_failures += 1
    except Exception as exc:
        permutation_failures += 1
        print(f"   Permutation {i} failed: {type(exc).__name__}: {exc}")

PERMUTATION_MIN_SUCCESS = max(10, int(0.5 * N_PLACEBO_PERMUTATIONS))
print(f"   Permutations: {len(placebo_effects)} successful, {permutation_failures} failed")
if len(placebo_effects) < PERMUTATION_MIN_SUCCESS:
    raise RuntimeError(
        f"Block-permutation placebo: only {len(placebo_effects)} successful "
        f"runs (need ≥{PERMUTATION_MIN_SUCCESS}); refuter cannot be trusted."
    )

# %% [markdown]
# ### Compared on t-Statistics, Not on Effect Sizes
#
# Permuting the treatment also frees it from the controls. The second stage regresses the
# residualized outcome on the residualized treatment, so whatever the controls explain leaves
# the denominator; a permuted treatment is no longer explained by them, its residual variance
# is larger, and a placebo effect is mechanically smaller than the observed one whether or not
# there is anything to find. Comparing raw effects against that distribution reports
# significance the standard error does not support. Each permutation's t-statistic divides by
# its own standard error, so the scale cancels and only the alignment between treatment and
# outcome is left.
#
# The p-value is the fraction of the placebo distribution at least as extreme as the observed
# statistic, and it carries a plus-one correction because the observed statistic is itself one
# draw that distribution could produce. Without the correction, a run in which no placebo
# reaches it reports zero, which no finite number of permutations can establish. With n draws
# the smallest reportable value is 1 / (n + 1), printed beside it as the floor. It is not a
# false discovery rate, which is what this quantity used to be called.
#
# **What cancelling the scale does not buy.** It removes one known bias and does not make the
# test calibrated. A permutation test is valid when the permuted labels are exchangeable under
# the null, and a treatment that persistent confounders predict is not: the shuffle breaks the
# confounding along with the effect, so an estimate that is itself biased sits far from its own
# permutation null and the test reports that distance. Measured on twelve synthetic panels with
# a true effect of exactly zero and persistent AR(1) confounders, the studentized comparison
# still rejects at the conventional five percent level on five of the twelve, against eleven for
# the raw-effect comparison. `10_case_study_insights` carries that measurement. Read the count below as a comparison against a shuffle
# rather than as a significance level.

# %%
if len(placebo_t_stats) > 10:
    placebo_mean = np.mean(placebo_t_stats)
    placebo_std = np.std(placebo_t_stats)
    z_score = (manual_t_hac - placebo_mean) / placebo_std if placebo_std > 0 else np.inf
    # The plus-one correction is why the floor below is 1 / (n + 1); see the markdown above.
    permutation_p = empirical_permutation_p(np.asarray(placebo_t_stats), manual_t_hac)

    print(f"   Placebo t mean: {placebo_mean:.4f}")
    print(f"   Placebo t std:  {placebo_std:.4f}")
    print(f"   Observed t (Driscoll-Kraay): {manual_t_hac:.4f}")
    print(f"   Z-score vs placebo: {z_score:.2f}")
    print(
        f"   Permutation p-value: {permutation_p:.4f} (floor {1 / (len(placebo_t_stats) + 1):.4f})"
    )

    print(
        f"   Placebo draws at least as extreme: "
        f"{int(round(permutation_p * (len(placebo_t_stats) + 1))) - 1} of {len(placebo_t_stats)}"
    )
    print(
        f"   Placebo effect spread {np.std(placebo_effects):.6f} against a Driscoll-Kraay "
        f"standard error of {manual_se_hac:.6f}; the comparison above is on t-statistics, "
        f"which holds whatever the ratio of those two turns out to be"
    )
else:
    print("   Insufficient successful permutations")
    z_score = None
    permutation_p = None

# %% [markdown]
# **The z-score and the p-value can disagree, and the count is the one that holds.** The
# z-score measures how far the observed t-statistic sits from the placebo *mean* in placebo
# standard deviations, which is a statement about a normal distribution centred where the
# placebos are. The permutation p-value counts how many placebo draws reach its magnitude.
# When the placebo distribution is not centred near zero the two answer different questions,
# and only the count is a statement about the null the test actually built. Read the count
# printed above, and the mean and standard deviation beside it, before reading the z-score.
#
# **This null is not centred at zero, and that is worth looking at rather than reporting.**
# A permutation that implemented "no association" would put the placebo statistics around
# zero. Two obvious explanations do not survive a check: demeaning the fitted residuals by
# symbol leaves the placebo estimates positive, and so does demeaning them by date, so
# neither a preserved between-symbol nor a preserved between-date component accounts for it.
# What does is not settled here. Until it is, the count is the statistic to read - it asks
# how often a permuted treatment reaches the observed statistic, which stays a fair question
# whatever the distribution's centre - and the z-score, which measures distance from that
# centre in placebo standard deviations, is a statement about a distribution the test has not
# explained.

# %% [markdown]
# The two halves are cut at a decision time rather than at a row, so neither holds a
# fragment of a cross-section. That is the same reason the subsample near the top keeps
# whole dates.

# %%
print("\n3. SUBSET STABILITY TEST (temporal halves)")
midpoint_date = unique_dates[len(unique_dates) // 2]
df_first_half = df[decision_times < midpoint_date]
df_second_half = df[decision_times >= midpoint_date]

if len(df_first_half) > 100 and len(df_second_half) > 100:
    result_first = manual_dml_timeseries(
        df_first_half[outcome_col].values,
        df_first_half[treatment_col].values,
        df_first_half[confounder_cols].values,
        n_folds=3,
        groups=df_first_half[mds.date_col].to_numpy(),
        horizon=FORWARD_HORIZON,
    )
    result_second = manual_dml_timeseries(
        df_second_half[outcome_col].values,
        df_second_half[treatment_col].values,
        df_second_half[confounder_cols].values,
        n_folds=3,
        groups=df_second_half[mds.date_col].to_numpy(),
        horizon=FORWARD_HORIZON,
    )

    effect_first = result_first["theta"]
    se_first = result_first["se_hac"]
    effect_second = result_second["theta"]
    se_second = result_second["se_hac"]

    print(f"   First half effect:  {effect_first:.6f} (SE: {se_first:.6f})")
    print(f"   Second half effect: {effect_second:.6f} (SE: {se_second:.6f})")

    diff = abs(effect_first - effect_second)
    diff_se = np.sqrt(se_first**2 + se_second**2)
    diff_z = diff / diff_se if diff_se > 0 else 0

    print(f"   Difference: {diff:.6f} (z = {diff_z:.2f})")

    print(f"   First half: {len(df_first_half):,} rows before {midpoint_date}")
    print(f"   Second half: {len(df_second_half):,} rows from {midpoint_date}")
else:
    print("   Insufficient data for subset test")

# %% [markdown]
# ## 6. Nuisance Model Sensitivity
#
# DML results depend on the quality of nuisance models. Let's compare different choices.

# %%
results = []

nuisance_models = [
    ("Linear", Ridge(alpha=1.0), Ridge(alpha=1.0)),
    (
        "GBM (shallow)",
        GradientBoostingRegressor(n_estimators=50, max_depth=2, random_state=SEED),
        GradientBoostingRegressor(n_estimators=50, max_depth=2, random_state=SEED),
    ),
    (
        "GBM (deep)",
        GradientBoostingRegressor(n_estimators=100, max_depth=5, random_state=SEED),
        GradientBoostingRegressor(n_estimators=100, max_depth=5, random_state=SEED),
    ),
]

cv_sensitivity = panel_folds(3)

for name, model_y, model_t in nuisance_models:
    dml_test = LinearDML(model_y=model_y, model_t=model_t, cv=cv_sensitivity, random_state=SEED)
    dml_test.fit(Y, T, W=W)
    ate = float(dml_test.ate())
    results.append({"Nuisance Model": name, "ATE Estimate": ate})

sensitivity_df = pd.DataFrame(results)
print("\n" + "=" * 60)
print("NUISANCE MODEL SENSITIVITY")
print("=" * 60)
print(sensitivity_df.to_string(index=False))
# A ratio of magnitudes reports 1.00x, which reads as no spread at all, when the
# specifications straddle zero symmetrically - the one case where the nuisance choice
# changes the conclusion rather than its size. The signed range cannot do that.
lo = float(sensitivity_df["ATE Estimate"].min())
hi = float(sensitivity_df["ATE Estimate"].max())
print(
    f"\nAcross {len(sensitivity_df)} nuisance specifications the estimate spans "
    f"{lo:.6f} to {hi:.6f}, a range of {hi - lo:.6f} in {outcome_col} units"
)
print(f"  Specifications agreeing in sign: {'all' if lo * hi > 0 else 'not all'}")

# %% [markdown]
# ## 7. Results Summary

# %%
print("\n" + "=" * 60)
print("CHAPTER 15 NOTEBOOK RESULTS: 03_econml_dml.py")
print("=" * 60)

results_summary = {
    "naive_estimate": naive_estimate,
    "naive_se_iid": se_iid,
    "naive_se_hac": se_hac,
    "naive_ci_lower": naive_ci[0],
    "naive_ci_upper": naive_ci[1],
    "manual_dml_estimate": manual_ate,
    "manual_dml_se_iid": manual_se_iid,
    "manual_dml_se_hac": manual_se_hac,
}

if dml_estimate is not None:
    results_summary["econml_dml_estimate"] = dml_estimate
    results_summary["econml_dml_ci_lower"] = dml_ci_lower
    results_summary["econml_dml_ci_upper"] = dml_ci_upper
    results_summary["bias_from_confounding"] = bias
    results_summary["bias_pct"] = bias_pct

if z_score is not None:
    results_summary["placebo_z_score"] = z_score
if permutation_p is not None:
    results_summary["permutation_p"] = permutation_p

for key, value in results_summary.items():
    if isinstance(value, float):
        print(f"{key}: {value:.6f}")
    else:
        print(f"{key}: {value}")

# %% [markdown]
# ## Key Methodological Points
#
# ### The four choices that decide whether the estimate means anything
# 1. **Cross-fitting with purging and embargo**, over decision times rather than rows.
#    Purging drops training rows whose labels overlap the test window; the embargo adds a
#    buffer after it (Chernozhukov et al. 2017, de Prado 2018).
# 2. **Driscoll-Kraay standard errors**, so the serial-correlation correction is applied
#    along time and the cross-sectional dependence within a date is absorbed rather than
#    counted as extra observations.
# 3. **Block permutation within entity**, so the placebo distribution is built from a
#    treatment that keeps its persistence.
# 4. **A nuisance-model sweep**, because the point estimate depends on the first stage and
#    the spread across specifications is the honest width of the finding.

# %% [markdown]
# ### Three Intervals on One Effect
#
# The notebook produces three statements of uncertainty about the same quantity, and they do
# not agree. EconML's `ate_interval` treats the residualized observations as independent
# draws. The manual DML path reports a Driscoll-Kraay standard error, which aggregates by
# decision time and absorbs whatever the ETFs share on a day. The permutation test compares
# the estimate against a null built by shuffling the treatment within each ETF.
#
# The spread between them is not a defect in any one of them. It is the price of the panel:
# an interval is only as good as its account of what is independent, and 52,000 ETF-days are
# not 52,000 independent observations.

# %%
se_inflation = se_hac / se_iid

print("Quantitative Findings")
print("-" * 40)
print(f"SE inflation, Driscoll-Kraay over iid: {se_inflation:.2f}x")
if dml_estimate is not None:
    direction = "larger" if abs(naive_estimate) > abs(dml_estimate) else "smaller"
    print(
        f"Adjustment moves the slope by {abs(bias_pct):.1f}% - the unadjusted estimate is "
        f"{direction} in magnitude"
    )
    if not np.isnan(dml_ci_lower):
        print(
            f"EconML DML: {dml_estimate:.6f}, iid 95% CI "
            f"[{dml_ci_lower:.6f}, {dml_ci_upper:.6f}], width {dml_ci_upper - dml_ci_lower:.6f}"
        )
    else:
        print(f"EconML DML: {dml_estimate:.6f} (interval unavailable)")
print(
    f"Manual DML:  {manual_ate:.6f}, Driscoll-Kraay 95% CI "
    f"[{manual_ci[0]:.6f}, {manual_ci[1]:.6f}], width {manual_ci[1] - manual_ci[0]:.6f}"
)
print(
    f"Naive OLS:   {naive_estimate:.6f}, Driscoll-Kraay 95% CI [{naive_ci[0]:.6f}, {naive_ci[1]:.6f}]"
)
if z_score is not None:
    print(f"Placebo z-score (on t-statistics): {z_score:.2f}")
if permutation_p is not None:
    print(f"Permutation p-value: {permutation_p:.4f}")

# %% [markdown]
# ## Key Takeaways
#
# 1. **DML is an adjusted estimate, not a free lunch**. The orthogonalized
#    coefficient is interpretable as a causal effect only under the maintained
#    assumptions: pre-treatment controls are sufficient, positivity holds, no
#    interference, and the specified DAG is correct.
#
# 2. **The robust standard error has to match the data's axis.** Overlapping 21-day labels
#    make the iid standard error too small, and on a stacked panel the ordinary Newey-West
#    correction fixes that along the wrong axis: its lags run across the cross-section.
#    Driscoll-Kraay aggregates by decision time first, which is what the SE inflation
#    printed above measures.
#
# 3. **Manual DML matches EconML conceptually and differs numerically.** The point estimate
#    moves with nuisance-model flexibility, and the sweep in section 6 shows by how much.
#    Neyman orthogonality is a guarantee about first-order sensitivity to nuisance error,
#    not about agreement across nuisance choices.
#
# 4. **A refutation counts in the units its name claims.** Block permutation preserves
#    autocorrelation only when the blocks run along an entity's own trading days; on a
#    flattened panel the same call permutes a slice of one day's cross-section, which is
#    the iid shuffle the test exists to avoid. The same is true of the walk-forward folds
#    and of the temporal halves.
#
# 5. **The sign of the confounding bias is a result, not a rule.** Here the controls change
#    the momentum slope in one direction; on the crypto funding premium in
#    `02_dowhy_causal_graph` they change it in the other. The comparison printed above says
#    which happened on this data, and it is the printed numbers, not this sentence, that
#    settle it.

```

출처의 라이선스에 따라 출처를 표시하고 전문을 공개합니다. 라이선스: MIT

이 요약은 원문을 바탕으로 Stratmill의 리서치 에이전트가 작성했으며, 원문을 복사한 것이 아닙니다.