Triply Robust Panel (TROP)#

Triply Robust Panel estimator for panel data with factor confounding.

This module implements the methodology from Athey, Imbens, Qu & Viviano (2025), which combines three robustness components:

  1. Nuclear norm regularized factor model: Estimates interactive fixed effects via matrix completion with nuclear norm penalty ||L||_*

  2. Exponential distance-based unit weights: ω_j = exp(-λ_unit × d(j,i)) where d(j,i) is the pairwise RMSE between units over pre-treatment periods

  3. Exponential time decay weights: θ_s = exp(-λ_time × \(|t-s|\)) weighting periods by proximity to the specific treatment period t

When to use TROP:

  • Suspected factor structure in the data (e.g., economic cycles, regional shocks)

  • Unobserved time-varying confounders that affect units differently over time

  • Standard parallel trends may be violated due to latent common factors

  • Reasonably long pre-treatment period to estimate factors

Reference: Athey, S., Imbens, G. W., Qu, Z., & Viviano, D. (2025). Triply Robust Panel Estimators. Working Paper. arXiv:2508.21536

TROP#

Main estimator class for Triply Robust Panel estimation.

class diff_diff.TROP[source]

Bases: TROPLocalMixin, TROPGlobalMixin

Triply Robust Panel (TROP) estimator.

Implements the exact methodology from Athey, Imbens, Qu & Viviano (2025). TROP combines three robustness components:

  1. Nuclear norm regularized factor model: Estimates interactive fixed effects L_it via matrix completion with nuclear norm penalty ||L||_*

  2. Exponential distance-based unit weights: ω_j = exp(-λ_unit × d(j,i)) where d(j,i) is the RMSE of outcome differences between units

  3. Exponential time decay weights: θ_s = exp(-λ_time × \(|s-t|\)) weighting pre-treatment periods by proximity to treatment

Tuning parameters (λ_time, λ_unit, λ_nn) are selected via leave-one-out cross-validation on control observations.

Parameters:
  • method (str, default='local') –

    Estimation method to use:

    • ’local’: Per-observation model fitting following Algorithm 2 of Athey et al. (2025). Computes observation-specific weights and fits a model for each treated observation, averaging the individual treatment effects. More flexible but computationally intensive.

    • ’global’: Computationally efficient adaptation using the (1-W) masking principle from Eq. 2. Fits a single model on control observations with global weights, then computes per-observation treatment effects as residuals: tau_it = Y_it - mu - alpha_i - beta_t - L_it for treated cells. ATT is the mean of these effects. For the paper’s full per-treated-cell estimator, use method='local'.

  • lambda_time_grid (list, optional) – Grid of time weight decay parameters. 0.0 = uniform weights (disabled). Must not contain inf. Default: [0, 0.1, 0.5, 1, 2, 5].

  • lambda_unit_grid (list, optional) – Grid of unit weight decay parameters. 0.0 = uniform weights (disabled). Must not contain inf. Default: [0, 0.1, 0.5, 1, 2, 5].

  • lambda_nn_grid (list, optional) – Grid of nuclear norm regularization parameters. inf = factor model disabled (L=0). Default: [0, 0.01, 0.1, 1].

  • max_iter (int, default=100) – Maximum iterations for nuclear norm optimization.

  • tol (float, default=1e-6) – Convergence tolerance for optimization.

  • alpha (float, default=0.05) – Significance level for confidence intervals.

  • n_bootstrap (int, default=200) – Number of bootstrap replications for variance estimation. Must be >= 2.

  • seed (int, optional) – Random seed for reproducibility.

results_

Estimation results after calling fit().

Type:

TROPResults

is_fitted_

Whether the model has been fitted.

Type:

bool

Examples

>>> from diff_diff import TROP
>>> trop = TROP()
>>> results = trop.fit(
...     data,
...     outcome='outcome',
...     treatment='treated',
...     unit='unit',
...     time='period',
... )
>>> results.print_summary()

References

Athey, S., Imbens, G. W., Qu, Z., & Viviano, D. (2025). Triply Robust Panel Estimators. Working Paper. https://arxiv.org/abs/2508.21536

Methods

fit(data, outcome, treatment, unit, time[, ...])

Fit the TROP model.

get_params()

Get estimator parameters.

set_params(**params)

Set estimator parameters.

__init__(method='local', lambda_time_grid=None, lambda_unit_grid=None, lambda_nn_grid=None, max_iter=100, tol=1e-06, alpha=0.05, n_bootstrap=200, seed=None)[source]
Parameters:
lambda_time_grid: List[float]
lambda_unit_grid: List[float]
lambda_nn_grid: List[float]
max_iter: int
tol: float
alpha: float
n_bootstrap: int
seed: int | None
results_: TROPResults | None
is_fitted_: bool
fit(data, outcome, treatment, unit, time, survey_design=None)[source]

Fit the TROP model.

Parameters:
  • data (pd.DataFrame) – Panel data with observations for multiple units over multiple time periods.

  • outcome (str) – Name of the outcome variable column.

  • treatment (str) –

    Name of the treatment indicator column (0/1).

    IMPORTANT: This should be an ABSORBING STATE indicator, not a treatment timing indicator. For each unit, D=1 for ALL periods during and after treatment:

    • D[t, i] = 0 for all t < g_i (pre-treatment periods)

    • D[t, i] = 1 for all t >= g_i (treatment and post-treatment)

    where g_i is the treatment start time for unit i.

    For staggered adoption, different units can have different g_i. The ATT averages over ALL D=1 cells per Equation 1 of the paper.

  • unit (str) – Name of the unit identifier column.

  • time (str) – Name of the time period column.

  • survey_design (SurveyDesign, optional) – Survey design specification. Supports pweight, strata, PSU, and FPC. Full-design surveys (strata/PSU/FPC) use Rao-Wu rescaled bootstrap; Rust backend is pweight-only (Python fallback for full design). Survey weights enter ATT aggregation only.

Returns:

Object containing the ATT estimate, standard error, factor estimates, and tuning parameters. The lambda_* attributes show the selected grid values. For lambda_time and lambda_unit, 0.0 means uniform weights; inf is not accepted. For lambda_nn, inf is converted to 1e10 (factor model disabled).

Return type:

TROPResults

Raises:

ValueError – If required columns are missing or non-pweight survey design.

get_params()[source]

Get estimator parameters.

Return type:

Dict[str, Any]

set_params(**params)[source]

Set estimator parameters.

Return type:

TROP

CONVERGENCE_TOL_SVD: float = 1e-10

TROPResults#

Results container for TROP estimation.

class diff_diff.TROPResults[source]

Bases: object

Results from a Triply Robust Panel (TROP) estimation.

TROP combines nuclear norm regularized factor estimation with exponential distance-based unit weights and time decay weights.

att

Average Treatment effect on the Treated (ATT).

Type:

float

se

Standard error of the ATT estimate.

Type:

float

t_stat

T-statistic for the ATT estimate.

Type:

float

p_value

P-value for the null hypothesis that ATT = 0.

Type:

float

conf_int

Confidence interval for the ATT.

Type:

tuple[float, float]

n_obs

Number of observations used in estimation.

Type:

int

n_treated

Number of treated units.

Type:

int

n_control

Number of control units.

Type:

int

n_treated_obs

Number of treated unit-time observations.

Type:

int

unit_effects

Estimated unit fixed effects (alpha_i).

Type:

dict

time_effects

Estimated time fixed effects (beta_t).

Type:

dict

treatment_effects

Individual treatment effects for each treated (unit, time) pair.

Type:

dict

lambda_time

Selected time weight decay parameter from grid. 0.0 = uniform time weights (disabled) per Eq. 3.

Type:

float

lambda_unit

Selected unit weight decay parameter from grid. 0.0 = uniform unit weights (disabled) per Eq. 3.

Type:

float

lambda_nn

Selected nuclear norm regularization parameter from grid. inf = factor model disabled (L=0); converted to 1e10 internally for computation.

Type:

float

factor_matrix

Estimated low-rank factor matrix L (n_periods x n_units).

Type:

np.ndarray

effective_rank

Effective rank of the factor matrix (sum of singular values / max).

Type:

float

loocv_score

Leave-one-out cross-validation score for selected parameters.

Type:

float

alpha

Significance level for confidence interval.

Type:

float

n_pre_periods

Number of pre-treatment periods.

Type:

int

n_post_periods

Number of post-treatment periods (periods with D=1 observations).

Type:

int

n_bootstrap

Number of bootstrap replications (if bootstrap variance).

Type:

int, optional

bootstrap_distribution

Bootstrap distribution of estimates.

Type:

np.ndarray, optional

Methods

summary([alpha])

Generate a formatted summary of the estimation results.

print_summary([alpha])

Print the summary to stdout.

to_dict()

Convert results to a dictionary.

to_dataframe()

Convert results to a pandas DataFrame.

get_treatment_effects_df()

Get individual treatment effects as a DataFrame.

get_unit_effects_df()

Get unit fixed effects as a DataFrame.

get_time_effects_df()

Get time fixed effects as a DataFrame.

att: float
se: float
t_stat: float
p_value: float
conf_int: Tuple[float, float]
n_obs: int
n_treated: int
n_control: int
n_treated_obs: int
unit_effects: Dict[Any, float]
time_effects: Dict[Any, float]
treatment_effects: Dict[Tuple[Any, Any], float]
lambda_time: float
lambda_unit: float
lambda_nn: float
factor_matrix: ndarray
effective_rank: float
loocv_score: float
alpha: float = 0.05
n_pre_periods: int = 0
n_post_periods: int = 0
n_bootstrap: int | None = None
bootstrap_distribution: ndarray | None = None
survey_metadata: Any | None = None
__init__(att, se, t_stat, p_value, conf_int, n_obs, n_treated, n_control, n_treated_obs, unit_effects, time_effects, treatment_effects, lambda_time, lambda_unit, lambda_nn, factor_matrix, effective_rank, loocv_score, alpha=0.05, n_pre_periods=0, n_post_periods=0, n_bootstrap=None, bootstrap_distribution=None, survey_metadata=None)
Parameters:
Return type:

None

__repr__()[source]

Concise string representation.

Return type:

str

property coef_var: float

SE / abs(ATT). NaN when ATT is 0 or SE non-finite.

Type:

Coefficient of variation

summary(alpha=None)[source]

Generate a formatted summary of the estimation results.

Parameters:

alpha (float, optional) – Significance level for confidence intervals. Defaults to the alpha used during estimation.

Returns:

Formatted summary table.

Return type:

str

print_summary(alpha=None)[source]

Print the summary to stdout.

Parameters:

alpha (float | None)

Return type:

None

to_dict()[source]

Convert results to a dictionary.

Returns:

Dictionary containing all estimation results.

Return type:

Dict[str, Any]

to_dataframe()[source]

Convert results to a pandas DataFrame.

Returns:

DataFrame with estimation results.

Return type:

pd.DataFrame

get_treatment_effects_df()[source]

Get individual treatment effects as a DataFrame.

Returns:

DataFrame with unit, time, and treatment effect columns.

Return type:

pd.DataFrame

get_unit_effects_df()[source]

Get unit fixed effects as a DataFrame.

Returns:

DataFrame with unit and effect columns.

Return type:

pd.DataFrame

get_time_effects_df()[source]

Get time fixed effects as a DataFrame.

Returns:

DataFrame with time and effect columns.

Return type:

pd.DataFrame

property is_significant: bool

Check if the ATT is statistically significant at the alpha level.

property significance_stars: str

Return significance stars based on p-value.

Convenience Function#

diff_diff.trop(data, outcome, treatment, unit, time, survey_design=None, **kwargs)[source]#

Convenience function for TROP estimation.

Parameters:
  • data (pd.DataFrame) – Panel data.

  • outcome (str) – Outcome variable column name.

  • treatment (str) –

    Treatment indicator column name (0/1).

    IMPORTANT: This should be an ABSORBING STATE indicator, not a treatment timing indicator. For each unit, D=1 for ALL periods during and after treatment (D[t,i]=0 for t < g_i, D[t,i]=1 for t >= g_i where g_i is the treatment start time for unit i).

  • unit (str) – Unit identifier column name.

  • time (str) – Time period column name.

  • survey_design (SurveyDesign, optional) – Survey design specification. Supports pweight, strata, PSU, and FPC.

  • **kwargs – Additional arguments passed to TROP constructor.

Returns:

Estimation results.

Return type:

TROPResults

Examples

>>> from diff_diff import trop
>>> results = trop(data, 'y', 'treated', 'unit', 'time')
>>> print(f"ATT: {results.att:.3f}")

Tuning Parameters#

TROP uses leave-one-out cross-validation (LOOCV) to select three tuning parameters:

Parameter

Description

Effect

λ_time

Time weight decay

Higher values weight periods closer to treatment more heavily

λ_unit

Unit distance decay

Higher values weight similar control units more heavily

λ_nn

Nuclear norm penalty

Higher values encourage lower-rank factor structure

Estimation Methods#

TROP supports two estimation methods via the method parameter:

Local Method (method='local', default)

The default method follows Algorithm 2 from the paper:

  1. Grid search with LOOCV: For each (λ_time, λ_unit, λ_nn) combination, compute cross-validation score by treating control observations as pseudo-treated

  2. Per-observation estimation: For each treated observation (i, t):

    1. Compute observation-specific weights θ^{i,t} and ω^{i,t}

    2. Fit weighted model: Y = α + β + L + ε with nuclear norm penalty on L

    3. Compute τ̂_{it} = Y_{it} - α̂_i - β̂_t - L̂_{it}

  3. Average: ATT = mean(τ̂_{it}) over all treated observations

This provides the triple robustness property (Theorem 5.1): the estimator is consistent if any one of the three components (unit weights, time weights, factor model) is correctly specified.

Global Method (method='global')

A computationally efficient adaptation using the (1-W) masking principle from Eq. 2. Fits a single global model rather than per-treated-cell models. For the paper’s full per-treated-cell estimator (Algorithm 2), use method='local'.

  1. Compute weights: Distance-based unit and time weights computed once (distance to center of treated block, RMSE to average treated trajectory), with (1-W) masking to zero out treated observations.

  2. Fit control model: Solve weighted least squares on control data only

    \[\min_{\mu, \alpha, \beta, L} \sum_{i,t} (1 - W_{it}) \delta_{it} (Y_{it} - \mu - \alpha_i - \beta_t - L_{it})^2 + \lambda_{nn} \|L\|_*\]
  3. Post-hoc treatment effects: For each treated observation:

    \[\hat{\tau}_{it} = Y_{it} - \hat{\mu} - \hat{\alpha}_i - \hat{\beta}_t - \hat{L}_{it}, \quad \text{ATT} = \text{mean}(\hat{\tau}_{it})\]

The global method is faster (single optimization vs N_treated optimizations). Treatment effects are heterogeneous per-observation residuals; ATT is their mean.

Feature

Local (default)

Global

Treatment effect

Per-observation τ_{it} (per-obs models)

Per-observation τ_{it} (single model)

Fitting

N_treated models with tailored weights

One model with global weights

Speed

Slower (N_treated fits)

Faster (single fit)

Weights

Observation-specific

Global (center of treated block)

Use method='local' for observation-specific weight optimization. Use method='global' for faster estimation with global weights.

Example Usage#

Basic usage:

from diff_diff import TROP

trop = TROP(
    lambda_time_grid=[0.0, 0.5, 1.0, 2.0],
    lambda_unit_grid=[0.0, 0.5, 1.0, 2.0],
    lambda_nn_grid=[0.0, 0.1, 1.0],
    n_bootstrap=200,
    seed=42
)

# Note: TROP infers treatment periods from the treatment indicator column.
# The treatment column should be an absorbing state (D=1 for all periods
# during and after treatment starts).
results = trop.fit(
    data,
    outcome='y',
    treatment='treated',
    unit='unit_id',
    time='period'
)
results.print_summary()

Quick estimation with convenience function:

from diff_diff import trop

results = trop(
    data,
    outcome='y',
    treatment='treated',
    unit='unit_id',
    time='period',
    n_bootstrap=200
)

Using the global method for faster estimation:

from diff_diff import TROP

# Global method: computationally efficient adaptation using (1-W) masking
trop_global = TROP(
    method='global',
    lambda_time_grid=[0.0, 0.5, 1.0, 2.0],
    lambda_unit_grid=[0.0, 0.5, 1.0, 2.0],
    lambda_nn_grid=[0.0, 0.1, 1.0],
    n_bootstrap=200,
    seed=42
)
results_global = trop_global.fit(data, outcome='y', treatment='treated',
                                  unit='unit_id', time='period')

# Compare methods
trop_local = TROP(method='local', ...)  # Default (per-observation)
results_local = trop_local.fit(data, ...)
print(f"Local ATT: {results_local.att:.3f}")
print(f"Global ATT: {results_global.att:.3f}")

Examining factor structure:

# Get the estimated factor matrix
L = results.factor_matrix
print(f"Effective rank: {results.effective_rank:.2f}")

# Individual treatment effects
effects_df = results.get_treatment_effects_df()
print(effects_df)

Comparison with Synthetic DiD#

TROP extends Synthetic DiD by adding factor model adjustment:

Feature

Synthetic DiD

TROP

Unit weights

Constrained to sum to 1

Exponential distance-based

Time weights

Constrained to sum to 1

Exponential time decay

Factor adjustment

None

Nuclear norm regularized L

Robustness

Doubly robust

Triply robust

Use SDID when parallel trends is plausible. Use TROP when you suspect factor confounding (regional shocks, economic cycles, latent factors).