06: Advanced SEM and ML Mediation Models

The previous notebooks built the discovery-quality mediation design, estimated a transparent linear g-computation decomposition, and stress-tested the result across definitions and specifications. This notebook adds a more advanced modeling layer.

The goal is to ask whether the same story survives with more flexible models and a more formal path-model framing. The goal is to ask whether the same story survives when we use more flexible models and a more formal path-model framing.

This notebook has four advanced pieces:

The main question remains the same: does high discovery exposure increase future user value, and is that effect meaningfully routed through same-day satisfaction depth?

Dataset and Mediation Design Context

This project uses KuaiRec interaction logs and metadata organized into active user-day records. The workflow constructs discovery-breadth, satisfaction-depth, quality-adjusted discovery, and shallow-click-pressure metrics from logged viewing behavior.

The data come from a real observational recommendation log. Discovery exposure, satisfaction depth, and future engagement arise from platform behavior, so the mediation analysis needs explicit identifying assumptions. The treatment is high discovery-breadth exposure, the mediator is same-day satisfaction depth, and the primary outcome is future seven-day interactions.

The project asks whether discovery exposure relates to future value directly, indirectly through satisfaction depth, or both. Because mediation in observational logs requires strong assumptions, the notebooks emphasize metric construction, temporal ordering, overlap, placebo-style checks, robustness across definitions, and model sensitivity.

Role of this notebook. This notebook compares SEM-style path models with cross-fitted machine-learning mediation workflows to assess model sensitivity.

Mathematical Setup

The SEM-style part of this notebook uses path coefficients. A simple linear mediation system is

\[ M_i = \alpha_0 + \alpha_1 A_i + \alpha_X^\top X_i + u_i, \]

\[ Y_i = \beta_0 + \beta_1 A_i + \beta_2 M_i + \beta_X^\top X_i + v_i. \]

The product (_1_2) is the linear indirect pathway, while (_1) is the direct pathway. The ML part relaxes the linear form by using flexible nuisance models, then estimates counterfactual differences through cross-fitted predictions. The teaching point is to compare transparent path estimates with more flexible prediction-based mediation estimates.

1. Load Libraries and Paths

This cell imports the modeling libraries used in the notebook. LightGBM and XGBoost are used for flexible nuisance models, while linear models provide the transparent baseline and SEM-style path estimates.

# Load libraries and paths.
from pathlib import Path
import os
import warnings

os.environ.setdefault("OMP_NUM_THREADS", "1")
os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
os.environ.setdefault("MKL_NUM_THREADS", "1")
os.environ.setdefault("NUMEXPR_NUM_THREADS", "1")

import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from IPython.display import display
from sklearn.base import clone
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from sklearn.model_selection import GroupKFold
from sklearn.preprocessing import StandardScaler

from lightgbm import LGBMRegressor
from xgboost import XGBRegressor

warnings.filterwarnings("ignore", category=FutureWarning)

sns.set_theme(style="whitegrid", context="notebook")
plt.rcParams["figure.figsize"] = (11, 6)
plt.rcParams["axes.titlesize"] = 13
plt.rcParams["axes.labelsize"] = 11
pd.set_option("display.max_columns", 180)
pd.set_option("display.max_colwidth", 140)

PROJECT_ROOT = Path.cwd().resolve()
while not (PROJECT_ROOT / "data").exists() and PROJECT_ROOT.parent != PROJECT_ROOT:
    PROJECT_ROOT = PROJECT_ROOT.parent

PROCESSED_DIR = PROJECT_ROOT / "data" / "processed"
NOTEBOOK_DIR = PROJECT_ROOT / "notebooks" / "discovery_quality_mediation"


ANALYSIS_PANEL_INPUT = PROCESSED_DIR / "kuairec_discovery_quality_mediation_analysis_panel.parquet"
EFFECT_SUMMARY_INPUT = PROCESSED_DIR / "kuairec_discovery_quality_effect_summary.csv"
ROBUSTNESS_SUMMARY_INPUT = PROCESSED_DIR / "kuairec_discovery_quality_robustness_summary.csv"

SEM_PATH_OUTPUT = PROCESSED_DIR / "kuairec_discovery_quality_sem_path_results.csv"
SEM_BOOTSTRAP_OUTPUT = PROCESSED_DIR / "kuairec_discovery_quality_sem_bootstrap.csv"
ML_EFFECTS_OUTPUT = PROCESSED_DIR / "kuairec_discovery_quality_advanced_ml_effects.csv"
ML_PERFORMANCE_OUTPUT = PROCESSED_DIR / "kuairec_discovery_quality_advanced_ml_performance.csv"
HETEROGENEITY_OUTPUT = PROCESSED_DIR / "kuairec_discovery_quality_advanced_heterogeneity.csv"
FEATURE_IMPORTANCE_OUTPUT = PROCESSED_DIR / "kuairec_discovery_quality_advanced_feature_importance.csv"
ADVANCED_SUMMARY_OUTPUT = PROCESSED_DIR / "kuairec_discovery_quality_advanced_model_summary.csv"

The thread settings keep LightGBM and XGBoost from taking over the machine. The dataset is small enough that single-threaded tree models are still fast and easier to reproduce.

2. Load Analysis Inputs

This cell loads the saved mediation analysis panel and the previous result tables. The advanced notebook should reuse the cleaned analysis object created earlier; it should reuse the cleaned analysis object created earlier.

analysis_panel = pd.read_parquet(ANALYSIS_PANEL_INPUT)
effect_summary = pd.read_csv(EFFECT_SUMMARY_INPUT)
robustness_summary = pd.read_csv(ROBUSTNESS_SUMMARY_INPUT)

load_summary = pd.DataFrame(
    {
        "artifact": ["analysis_panel", "effect_summary", "robustness_summary"],
        "rows": [len(analysis_panel), len(effect_summary), len(robustness_summary)],
        "columns": [analysis_panel.shape[1], effect_summary.shape[1], robustness_summary.shape[1]],
    }
)

main_effects = effect_summary.query(
    "outcome == 'Y_future_interactions' and estimand in ['gcomp_total_effect', 'natural_direct_effect', 'natural_indirect_effect']"
)[["estimand", "estimate", "ci_95_lower", "ci_95_upper", "relative_effect"]]

display(load_summary)
display(main_effects.round(4))
display(robustness_summary.round(4))
artifact rows columns
0 analysis_panel 8199 103
1 effect_summary 21 12
2 robustness_summary 4 9
estimand estimate ci_95_lower ci_95_upper relative_effect
2 gcomp_total_effect 37.2781 32.2444 42.8135 0.1152
3 natural_direct_effect 38.5858 33.3634 43.7154 0.1192
4 natural_indirect_effect -1.3077 -2.3047 -0.4290 -0.0036
robustness_family specifications total_effect_min total_effect_max total_effect_share_positive indirect_effect_min indirect_effect_max indirect_effect_share_positive median_abs_indirect_to_total_ratio
0 threshold_sensitivity 4 37.2781 40.0555 1.0 -1.6532 -1.3077 0.0 0.0402
1 mediator_sensitivity 5 37.2560 37.3502 1.0 -1.7145 0.1281 0.2 0.0359
2 model_sensitivity 5 34.8085 37.6289 1.0 -1.5581 -0.9360 0.0 0.0351
3 outcome_sensitivity 1 37.2781 37.2781 1.0 -1.3077 -1.3077 0.0 0.0351

The earlier result is the benchmark. The advanced models should be judged against this reference because model flexibility alone is insufficient evidence of credibility. This keeps the analysis auditable: a reader can trace the final claim back to the exact table, figure, or markdown artifact that produced it.

3. Define Variables and Control Sets

This cell defines the treatment, mediator, outcome, and covariates. The covariates are the same pre-treatment history and profile features used in prior notebooks. This keeps the comparison focused on model form.

# Define variables and control sets.
TREATMENT = "A_high_discovery"
MEDIATOR = "M_satisfaction_depth"
PRIMARY_OUTCOME = "Y_future_interactions"
SECONDARY_OUTCOME = "Y_future_play_hours"
WEIGHT_COLUMN = "stabilized_ipw_capped"
GROUP_COLUMN = "user_id"
RANDOM_SEED = 42
N_SPLITS = 3
SEM_BOOTSTRAP_ITERATIONS = 100

base_numeric_covariates = [
    "calendar_day_index",
    "lag_1_active_day",
    "prior_3day_active_day",
    "lag_1_interactions",
    "prior_3day_interactions",
    "lag_1_total_play_duration_sec",
    "prior_3day_total_play_duration_sec",
    "lag_1_valid_play_share",
    "prior_3day_valid_play_share",
    "lag_1_high_satisfaction_share",
    "prior_3day_high_satisfaction_share",
    "lag_1_discovery_candidate_share",
    "prior_3day_discovery_candidate_share",
    "recent_activity_score",
    "register_days",
    "follow_user_num",
    "fans_user_num",
    "friend_user_num",
    "is_lowactive_period",
    "is_live_streamer",
    "is_video_author",
]
profile_onehot_covariates = [col for col in analysis_panel.columns if col.startswith("onehot_feat")]
numeric_covariates = [col for col in base_numeric_covariates + profile_onehot_covariates if col in analysis_panel.columns]
categorical_covariates = [
    col
    for col in [
        "user_active_degree",
        "follow_user_num_range",
        "fans_user_num_range",
        "friend_user_num_range",
        "register_days_range",
    ]
    if col in analysis_panel.columns
]

variable_summary = pd.DataFrame(
    {
        "item": ["treatment", "mediator", "primary_outcome", "secondary_outcome", "numeric_covariates", "categorical_covariates", "group_folds"],
        "value": [TREATMENT, MEDIATOR, PRIMARY_OUTCOME, SECONDARY_OUTCOME, len(numeric_covariates), len(categorical_covariates), N_SPLITS],
    }
)

display(variable_summary)
item value
0 treatment A_high_discovery
1 mediator M_satisfaction_depth
2 primary_outcome Y_future_interactions
3 secondary_outcome Y_future_play_hours
4 numeric_covariates 39
5 categorical_covariates 5
6 group_folds 3

The cross-fitting group is user_id, so the same user’s days are kept out of both train and validation folds. That is important because each user contributes repeated active days.

4. Build the Modeling Matrix

This cell prepares the adjustment matrix used by the SEM and ML models. Numeric columns are median-filled, categorical columns are one-hot encoded, and constant columns are removed.

# Build the modeling matrix.
def make_safe_column_names(columns):
    """
    Create formula-safe column names for SEM-style modeling.
    
    Idea
    ----
    The helper removes characters that can confuse formula parsers while preserving a mapping back to readable covariate names.
    
    Parameters
    ----------
    columns : object
        Column names to clean or preserve.
    
    Returns
    -------
    tuple[list[str], dict[str, str]]
        Safe column names and mapping from original names to safe names.
    """
    safe_names = []
    counts = {}
    for col in columns:
        safe = "".join(ch if ch.isalnum() else "_" for ch in str(col))
        safe = "_".join(part for part in safe.split("_") if part)
        if not safe:
            safe = "feature"
        if safe[0].isdigit():
            safe = f"feature_{safe}"
        counts[safe] = counts.get(safe, 0) + 1
        if counts[safe] > 1:
            safe = f"{safe}_{counts[safe]}"
        safe_names.append(safe)
    return safe_names


def build_covariate_matrix(frame, numeric_cols, categorical_cols):
    """
    Build a numeric covariate matrix for mediation adjustment.
    
    Idea
    ----
    The helper combines numeric and categorical pre-treatment covariates into a model matrix used by treatment, mediator, and outcome models.
    
    Parameters
    ----------
    frame : object
        Input analysis table for the current project step.
    numeric_cols : object
        Numeric covariate columns used by the model.
    categorical_cols : object
        Categorical covariate columns used by the model.
    
    Returns
    -------
    pandas.DataFrame
        Design matrix aligned with the input frame.
    """
    numeric = frame[numeric_cols].copy()
    for col in numeric.columns:
        numeric[col] = pd.to_numeric(numeric[col], errors="coerce")
        numeric[col] = numeric[col].fillna(numeric[col].median())

    if categorical_cols:
        categorical = frame[categorical_cols].copy()
        for col in categorical.columns:
            categorical[col] = categorical[col].astype("string").fillna("missing")
        encoded = pd.get_dummies(categorical, drop_first=True, dtype=float)
        design = pd.concat([numeric, encoded], axis=1)
    else:
        design = numeric

    design = design.loc[:, design.nunique(dropna=False) > 1]
    design.columns = make_safe_column_names(design.columns)
    return design.astype(float)

X_covariates = build_covariate_matrix(analysis_panel, numeric_covariates, categorical_covariates)
A = analysis_panel[TREATMENT].astype(float).to_numpy()
M = analysis_panel[MEDIATOR].astype(float).to_numpy()
Y = analysis_panel[PRIMARY_OUTCOME].astype(float).to_numpy()
weights = analysis_panel[WEIGHT_COLUMN].astype(float).to_numpy()
groups = analysis_panel[GROUP_COLUMN].to_numpy()

matrix_summary = pd.DataFrame(
    {
        "item": ["rows", "covariate_columns", "treatment_rate", "mediator_mean", "outcome_mean", "unique_users"],
        "value": [len(analysis_panel), X_covariates.shape[1], A.mean(), M.mean(), Y.mean(), analysis_panel[GROUP_COLUMN].nunique()],
    }
)

display(matrix_summary)
display(X_covariates.head())
item value
0 rows 8199.000000
1 covariate_columns 54.000000
2 treatment_rate 0.500061
3 mediator_mean 0.618626
4 outcome_mean 340.694475
5 unique_users 133.000000
calendar_day_index lag_1_active_day prior_3day_active_day lag_1_interactions prior_3day_interactions lag_1_total_play_duration_sec prior_3day_total_play_duration_sec lag_1_valid_play_share prior_3day_valid_play_share lag_1_high_satisfaction_share prior_3day_high_satisfaction_share lag_1_discovery_candidate_share prior_3day_discovery_candidate_share recent_activity_score register_days follow_user_num fans_user_num friend_user_num is_video_author onehot_feat0 onehot_feat1 onehot_feat2 onehot_feat3 onehot_feat4 onehot_feat6 onehot_feat7 onehot_feat8 onehot_feat9 onehot_feat10 onehot_feat11 onehot_feat12 onehot_feat13 onehot_feat14 onehot_feat15 onehot_feat17 user_active_degree_full_active user_active_degree_high_active follow_user_num_range_10_50 follow_user_num_range_100_150 follow_user_num_range_150_250 follow_user_num_range_250_500 follow_user_num_range_50_100 follow_user_num_range_0 follow_user_num_range_500 fans_user_num_range_1_10 fans_user_num_range_10_100 friend_user_num_range_1_5 friend_user_num_range_30_60 friend_user_num_range_5_30 register_days_range_31_60 register_days_range_366_730 register_days_range_61_90 register_days_range_730 register_days_range_91_180
0 0.0 0.0 0.0 0.0 0.0 0.000 0.000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 224.0 7.0 3.0 0.0 0.0 0.0 1.0 24.0 876.0 1.0 1.0 4.0 98.0 6.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1 1.0 1.0 1.0 32.0 32.0 163.970 163.970 0.937500 0.937500 0.156250 0.156250 0.687500 0.687500 0.562990 224.0 7.0 3.0 0.0 0.0 0.0 1.0 24.0 876.0 1.0 1.0 4.0 98.0 6.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
2 2.0 1.0 2.0 20.0 52.0 130.986 294.956 0.950000 1.887500 0.350000 0.506250 0.450000 1.137500 0.639277 224.0 7.0 3.0 0.0 0.0 0.0 1.0 24.0 876.0 1.0 1.0 4.0 98.0 6.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
3 3.0 1.0 3.0 16.0 68.0 100.920 395.876 1.000000 2.887500 0.187500 0.693750 0.437500 1.575000 0.681755 224.0 7.0 3.0 0.0 0.0 0.0 1.0 24.0 876.0 1.0 1.0 4.0 98.0 6.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
4 4.0 1.0 3.0 37.0 73.0 222.720 454.626 0.891892 2.841892 0.216216 0.753716 0.432432 1.319932 0.693019 224.0 7.0 3.0 0.0 0.0 0.0 1.0 24.0 876.0 1.0 1.0 4.0 98.0 6.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0

The modeling matrix is intentionally reused across methods. SEM, Linear Regression, LightGBM, and XGBoost all see the same observed adjustment information. The teaching point is that weighting can reduce bias while also reducing usable information, so stability matters as much as the point estimate.

Advanced Mediation Workflow

SEM-Style Path Model

This cell estimates a simple path model:

  • M = alpha * A + X + error
  • Y = c_prime * A + beta * M + X + error
  • indirect = alpha * beta
  • total_path = c_prime + alpha * beta

This is a transparent path-analysis version of the mediation story. It is a transparent path-analysis version of the mediation story, which is often enough for product analytics settings where the variables are observed scores.

# SEM-Style path model.
def design_with_columns(covariates, **named_arrays):
    """
    Create a design matrix with a fixed column order.
    
    Idea
    ----
    The helper keeps SEM-style and machine-learning models aligned when training and prediction frames are built separately.
    
    Parameters
    ----------
    covariates : object
        Project-specific input named `covariates` used by this helper.
    
    Returns
    -------
    pandas.DataFrame
        Design matrix with the requested columns in a stable order.
    """
    design = covariates.copy()
    for name, values in reversed(list(named_arrays.items())):
        design.insert(0, name, np.asarray(values, dtype=float))
    return design


def fit_sem_path(frame, covariates):
    """
    Fit the SEM-style mediation path model.
    
    Idea
    ----
    The function estimates treatment-to-mediator, treatment-to-outcome, and mediator-to-outcome paths in a compact linear system for comparison with g-computation.
    
    Parameters
    ----------
    frame : object
        Input analysis table for the current project step.
    covariates : object
        Project-specific input named `covariates` used by this helper.
    
    Returns
    -------
    dict
        Path coefficients and implied direct, indirect, and total effects.
    """
    a = frame[TREATMENT].astype(float).to_numpy()
    m = frame[MEDIATOR].astype(float).to_numpy()
    y = frame[PRIMARY_OUTCOME].astype(float).to_numpy()
    w = frame[WEIGHT_COLUMN].astype(float).to_numpy()

    mediator_design = design_with_columns(covariates, A=a)
    mediator_model = LinearRegression().fit(mediator_design, m, sample_weight=w)
    alpha = float(mediator_model.coef_[0])

    outcome_design = design_with_columns(covariates, A=a, M=m)
    outcome_model = LinearRegression().fit(outcome_design, y, sample_weight=w)
    c_prime = float(outcome_model.coef_[0])
    beta = float(outcome_model.coef_[1])

    total_design = design_with_columns(covariates, A=a)
    total_model = LinearRegression().fit(total_design, y, sample_weight=w)
    total_coefficient = float(total_model.coef_[0])

    return {
        "alpha_A_to_M": alpha,
        "beta_M_to_Y": beta,
        "c_prime_A_to_Y": c_prime,
        "indirect_alpha_beta": alpha * beta,
        "total_path_c_prime_plus_indirect": c_prime + alpha * beta,
        "total_regression_coefficient": total_coefficient,
        "mediator_model_r2": mediator_model.score(mediator_design, m, sample_weight=w),
        "outcome_model_r2": outcome_model.score(outcome_design, y, sample_weight=w),
        "total_model_r2": total_model.score(total_design, y, sample_weight=w),
    }

sem_path_results = pd.DataFrame([fit_sem_path(analysis_panel, X_covariates)])

display(sem_path_results.round(4))
alpha_A_to_M beta_M_to_Y c_prime_A_to_Y indirect_alpha_beta total_path_c_prime_plus_indirect total_regression_coefficient mediator_model_r2 outcome_model_r2 total_model_r2
0 0.0231 -48.4626 38.4311 -1.1172 37.3139 37.3139 0.4653 0.6642 0.6637

The SEM-style table gives a compact path decomposition. If alpha is positive while beta is negative, high discovery can increase satisfaction depth while another pathway carries future interaction gains.

Bootstrap the SEM-Style Paths by User

This cell bootstraps the path model by resampling users. The purpose is to quantify how stable the SEM-style path coefficients are under the repeated-user structure.

# Bootstrap the SEM-Style paths by user.
rng = np.random.default_rng(RANDOM_SEED)
unique_users = np.sort(analysis_panel[GROUP_COLUMN].unique())
indexed_panel = analysis_panel.set_index(GROUP_COLUMN, drop=False)
sem_bootstrap_rows = []

for iteration in range(SEM_BOOTSTRAP_ITERATIONS):
    sampled_users = rng.choice(unique_users, size=len(unique_users), replace=True)
    sample = indexed_panel.loc[sampled_users].reset_index(drop=True)
    sample_covariates = build_covariate_matrix(sample, numeric_covariates, categorical_covariates)
    sample_covariates = sample_covariates.reindex(columns=X_covariates.columns, fill_value=0.0)
    row = fit_sem_path(sample, sample_covariates)
    row["bootstrap_iteration"] = iteration
    sem_bootstrap_rows.append(row)

sem_bootstrap = pd.DataFrame(sem_bootstrap_rows)
sem_interval_rows = []
for column in [
    "alpha_A_to_M",
    "beta_M_to_Y",
    "c_prime_A_to_Y",
    "indirect_alpha_beta",
    "total_path_c_prime_plus_indirect",
    "total_regression_coefficient",
]:
    sem_interval_rows.append(
        {
            "path_quantity": column,
            "estimate": sem_path_results.loc[0, column],
            "bootstrap_mean": sem_bootstrap[column].mean(),
            "ci_95_lower": sem_bootstrap[column].quantile(0.025),
            "ci_95_upper": sem_bootstrap[column].quantile(0.975),
        }
    )

sem_path_summary = pd.DataFrame(sem_interval_rows)

display(sem_path_summary.round(4))
path_quantity estimate bootstrap_mean ci_95_lower ci_95_upper
0 alpha_A_to_M 0.0231 0.0235 0.0183 0.0281
1 beta_M_to_Y -48.4626 -48.4765 -74.0799 -21.9557
2 c_prime_A_to_Y 38.4311 38.7951 32.6143 44.0143
3 indirect_alpha_beta -1.1172 -1.1509 -1.8332 -0.4317
4 total_path_c_prime_plus_indirect 37.3139 37.6442 31.4660 42.8712
5 total_regression_coefficient 37.3139 37.6442 31.4660 42.8712

The bootstrapped path table is the formal counterpart to the earlier g-computation results. It lets us compare whether the simple path-product indirect effect points in the same direction as the notebook 04 indirect estimate.

7. Plot SEM Path Quantities

This cell plots the SEM-style path quantities with bootstrap intervals. The visual focuses on the direct path, indirect path, and total path because these are the quantities that correspond most closely to the earlier decomposition.

# Plot SEM path quantities.
sem_plot = sem_path_summary.query(
    "path_quantity in ['c_prime_A_to_Y', 'indirect_alpha_beta', 'total_path_c_prime_plus_indirect', 'total_regression_coefficient']"
).copy()
sem_plot["label"] = sem_plot["path_quantity"].map(
    {
        "c_prime_A_to_Y": "Direct path",
        "indirect_alpha_beta": "Indirect path",
        "total_path_c_prime_plus_indirect": "Direct + indirect path",
        "total_regression_coefficient": "Total regression path",
    }
)
sem_plot["lower_error"] = sem_plot["estimate"] - sem_plot["ci_95_lower"]
sem_plot["upper_error"] = sem_plot["ci_95_upper"] - sem_plot["estimate"]

fig, ax = plt.subplots(figsize=(10, 5.5))
sns.barplot(data=sem_plot, x="estimate", y="label", color="steelblue", ax=ax)
for row_index, row in sem_plot.reset_index(drop=True).iterrows():
    ax.errorbar(
        x=row["estimate"],
        y=row_index,
        xerr=[[row["lower_error"]], [row["upper_error"]]],
        fmt="none",
        color="black",
        capsize=4,
        linewidth=1.2,
    )
ax.axvline(0, color="black", linewidth=1)
ax.set_title("SEM-Style Path Decomposition")
ax.set_xlabel("Effect on future 7-day interactions")
ax.set_ylabel("")
plt.tight_layout()
plt.show()

The SEM plot summarizes the existing path-model assumptions and coefficients. It is useful because it compresses the mediation story into a few path coefficients.

Define Cross-Fitted ML Nuisance Models

This cell defines the model families used in the cross-fitted g-computation. The Linear model is the transparent baseline. LightGBM and XGBoost allow nonlinearities and interactions in the nuisance functions.

# Define cross-fitted ML nuisance models.
model_factories = {
    "linear": lambda: LinearRegression(),
    "lightgbm": lambda: LGBMRegressor(
        n_estimators=80,
        learning_rate=0.05,
        num_leaves=24,
        subsample=0.85,
        colsample_bytree=0.85,
        random_state=RANDOM_SEED,
        n_jobs=1,
        verbose=-1,
    ),
    "xgboost": lambda: XGBRegressor(
        n_estimators=80,
        max_depth=3,
        learning_rate=0.05,
        subsample=0.85,
        colsample_bytree=0.85,
        objective="reg:squarederror",
        tree_method="hist",
        random_state=RANDOM_SEED,
        n_jobs=1,
        verbosity=0,
    ),
}

model_summary = pd.DataFrame(
    {
        "model_family": list(model_factories.keys()),
        "role": [
            "transparent benchmark",
            "tree-based nonlinear nuisance model",
            "boosted-tree nonlinear nuisance model",
        ],
    }
)

display(model_summary)
model_family role
0 linear transparent benchmark
1 lightgbm tree-based nonlinear nuisance model
2 xgboost boosted-tree nonlinear nuisance model

All three model families estimate the same counterfactual quantities. That keeps the comparison fair because nuisance flexibility changes while the causal estimand stays fixed. The teaching point is that weighting can reduce bias while also reducing usable information, so stability matters as much as the point estimate.

9. Implement Cross-Fitted ML G-Computation

This cell implements group cross-fitting. For each fold, models are trained on some users and evaluated on held-out users. The estimator predicts M(0), M(1), Y(0, M(0)), Y(1, M(0)), and Y(1, M(1)) out of fold, then averages the implied effects.

# Implement cross-fitted ML g-computation.
def treatment_design(covariates, treatment_values):
    """
    Create a design matrix for treatment or mediator models.
    
    Idea
    ----
    The helper appends treatment values to the covariate matrix so counterfactual mediator or outcome predictions can be generated.
    
    Parameters
    ----------
    covariates : object
        Project-specific input named `covariates` used by this helper.
    treatment_values : object
        Counterfactual or observed treatment values to place in the design matrix.
    
    Returns
    -------
    pandas.DataFrame
        Design matrix containing covariates and treatment values.
    """
    design = covariates.copy()
    design.insert(0, TREATMENT, np.asarray(treatment_values, dtype=float))
    return design


def outcome_design(covariates, treatment_values, mediator_values):
    """
    Create a design matrix for mediation outcome models.
    
    Idea
    ----
    The helper combines covariates, treatment values, and mediator values so direct and indirect pathways can be predicted under counterfactual settings.
    
    Parameters
    ----------
    covariates : object
        Project-specific input named `covariates` used by this helper.
    treatment_values : object
        Counterfactual or observed treatment values to place in the design matrix.
    mediator_values : object
        Mediator values to place in the outcome design matrix.
    
    Returns
    -------
    pandas.DataFrame
        Outcome-model design matrix.
    """
    treatment_array = np.asarray(treatment_values, dtype=float)
    mediator_array = np.asarray(mediator_values, dtype=float)
    design = covariates.copy()
    design.insert(0, "A_x_M", treatment_array * mediator_array)
    design.insert(0, MEDIATOR, mediator_array)
    design.insert(0, TREATMENT, treatment_array)
    return design


def fit_with_optional_weight(model, X, y, sample_weight=None):
    """
    Fit a model with sample weights when supported.
    
    Idea
    ----
    The helper lets the mediation workflow use the same call pattern for weighted and unweighted model families.
    
    Parameters
    ----------
    model : object
        Fitted or unfitted model used in the helper.
    X : object
        Feature matrix.
    y : object
        Target vector.
    sample_weight : object
        Optional observation weights for model fitting.
    
    Returns
    -------
    sklearn estimator
        Fitted model instance.
    """
    try:
        model.fit(X, y, sample_weight=sample_weight)
    except TypeError:
        model.fit(X, y)
    return model


def regression_metrics(y_true, y_pred):
    """
    Compute regression diagnostics for nuisance predictions.
    
    Idea
    ----
    The helper summarizes predictive performance so ML mediation estimates can be judged alongside nuisance-model quality.
    
    Parameters
    ----------
    y_true : object
        Observed target values.
    y_pred : object
        Predicted target values.
    
    Returns
    -------
    dict
        Regression metrics such as RMSE, MAE, and R-squared.
    """
    rmse = mean_squared_error(y_true, y_pred) ** 0.5
    return {
        "rmse": rmse,
        "mae": mean_absolute_error(y_true, y_pred),
        "r2": r2_score(y_true, y_pred),
    }


def crossfit_ml_mediation(model_name, model_factory, outcome_col=PRIMARY_OUTCOME):
    """
    Run cross-fitted machine-learning mediation for one outcome.
    
    Idea
    ----
    The function fits mediator and outcome nuisance models out of fold, predicts counterfactual pathways, and summarizes how ML-based effects compare with linear mediation.
    
    Parameters
    ----------
    model_name : object
        Label identifying the model family or specification.
    model_factory : object
        Callable that creates a fresh model for each fold.
    outcome_col : object
        Outcome column used in the estimator.
    
    Returns
    -------
    pandas.DataFrame
        Cross-fitted mediation effect estimates and nuisance-model diagnostics.
    """
    n = len(analysis_panel)
    splitter = GroupKFold(n_splits=N_SPLITS)

    m_hat = np.zeros(n)
    y_hat = np.zeros(n)
    m0 = np.zeros(n)
    m1 = np.zeros(n)
    y_0_m0 = np.zeros(n)
    y_1_m0 = np.zeros(n)
    y_1_m1 = np.zeros(n)
    y_0_m1 = np.zeros(n)

    fold_rows = []
    for fold_id, (train_idx, test_idx) in enumerate(splitter.split(X_covariates, A, groups=groups), start=1):
        X_train = X_covariates.iloc[train_idx]
        X_test = X_covariates.iloc[test_idx]
        A_train = A[train_idx]
        M_train = M[train_idx]
        Y_train = analysis_panel[outcome_col].astype(float).to_numpy()[train_idx]
        W_train = weights[train_idx]

        mediator_model = model_factory()
        outcome_model = model_factory()

        mediator_model = fit_with_optional_weight(
            mediator_model,
            treatment_design(X_train, A_train),
            M_train,
            sample_weight=W_train,
        )
        train_outcome_design = outcome_design(X_train, A_train, M_train)
        outcome_model = fit_with_optional_weight(
            outcome_model,
            train_outcome_design,
            Y_train,
            sample_weight=W_train,
        )

        zeros = np.zeros(len(test_idx))
        ones = np.ones(len(test_idx))
        observed_a = A[test_idx]
        observed_m = M[test_idx]

        m_hat[test_idx] = mediator_model.predict(treatment_design(X_test, observed_a)).clip(0, 1)
        m0[test_idx] = mediator_model.predict(treatment_design(X_test, zeros)).clip(0, 1)
        m1[test_idx] = mediator_model.predict(treatment_design(X_test, ones)).clip(0, 1)

        y_hat[test_idx] = outcome_model.predict(outcome_design(X_test, observed_a, observed_m))
        y_0_m0[test_idx] = outcome_model.predict(outcome_design(X_test, zeros, m0[test_idx]))
        y_1_m0[test_idx] = outcome_model.predict(outcome_design(X_test, ones, m0[test_idx]))
        y_1_m1[test_idx] = outcome_model.predict(outcome_design(X_test, ones, m1[test_idx]))
        y_0_m1[test_idx] = outcome_model.predict(outcome_design(X_test, zeros, m1[test_idx]))

        fold_rows.append(
            {
                "model_family": model_name,
                "outcome": outcome_col,
                "fold": fold_id,
                "train_rows": len(train_idx),
                "test_rows": len(test_idx),
                "test_users": len(np.unique(groups[test_idx])),
            }
        )

    observed_y = analysis_panel[outcome_col].astype(float).to_numpy()
    mediator_metrics = regression_metrics(M, m_hat)
    outcome_metrics = regression_metrics(observed_y, y_hat)

    effects = {
        "model_family": model_name,
        "outcome": outcome_col,
        "outcome_label": "Future 7-day interactions" if outcome_col == PRIMARY_OUTCOME else "Future 7-day play hours",
        "gcomp_total_effect": float(np.mean(y_1_m1 - y_0_m0)),
        "natural_direct_effect": float(np.mean(y_1_m0 - y_0_m0)),
        "natural_indirect_effect": float(np.mean(y_1_m1 - y_1_m0)),
        "reverse_indirect_check": float(np.mean(y_0_m1 - y_0_m0)),
        "mediator_shift": float(np.mean(m1 - m0)),
        "reference_mean": float(np.mean(y_0_m0)),
        "relative_total_effect": float(np.mean(y_1_m1 - y_0_m0) / np.mean(y_0_m0)),
    }

    performance = [
        {"model_family": model_name, "outcome": outcome_col, "target": "mediator", **mediator_metrics},
        {"model_family": model_name, "outcome": outcome_col, "target": "outcome", **outcome_metrics},
    ]

    row_level = pd.DataFrame(
        {
            "user_id": analysis_panel[GROUP_COLUMN].to_numpy(),
            "event_date": analysis_panel["event_date"].to_numpy(),
            "model_family": model_name,
            "outcome": outcome_col,
            "ite_total": y_1_m1 - y_0_m0,
            "ite_direct": y_1_m0 - y_0_m0,
            "ite_indirect": y_1_m1 - y_1_m0,
            "m0_hat": m0,
            "m1_hat": m1,
            "m_hat": m_hat,
            "y_hat": y_hat,
        }
    )

    return effects, performance, pd.DataFrame(fold_rows), row_level

print("Cross-fitted ML mediation function ready.")
Cross-fitted ML mediation function ready.

The row-level output is useful for heterogeneity. Each held-out row receives an estimated total, direct, and indirect effect, which can then be summarized by user segments.

Run Cross-Fitted ML Mediation

This cell runs the cross-fitted estimator for Linear Regression, LightGBM, and XGBoost. It estimates the primary future-interaction outcome and a secondary future-play-hours outcome.

ml_effect_rows = []
ml_performance_rows = []
fold_summaries = []
row_level_effect_frames = []

for outcome_col in [PRIMARY_OUTCOME, SECONDARY_OUTCOME]:
    for model_name, model_factory in model_factories.items():
        effects, performance, folds, row_level = crossfit_ml_mediation(
            model_name,
            model_factory,
            outcome_col=outcome_col,
        )
        ml_effect_rows.append(effects)
        ml_performance_rows.extend(performance)
        fold_summaries.append(folds)
        row_level_effect_frames.append(row_level)

ml_effects = pd.DataFrame(ml_effect_rows)
ml_performance = pd.DataFrame(ml_performance_rows)
ml_fold_summary = pd.concat(fold_summaries, ignore_index=True)
row_level_effects = pd.concat(row_level_effect_frames, ignore_index=True)

display(ml_effects.round(4))
display(ml_performance.round(4))
model_family outcome outcome_label gcomp_total_effect natural_direct_effect natural_indirect_effect reverse_indirect_check mediator_shift reference_mean relative_total_effect
0 linear Y_future_interactions Future 7-day interactions 37.3421 38.6464 -1.3043 -0.9904 0.0231 322.8968 0.1156
1 lightgbm Y_future_interactions Future 7-day interactions 3.2069 3.2652 -0.0583 -0.0137 0.0184 339.1834 0.0095
2 xgboost Y_future_interactions Future 7-day interactions 2.6829 2.7279 -0.0450 -0.0294 0.0138 339.7969 0.0079
3 linear Y_future_play_hours Future 7-day play hours 0.0786 0.0745 0.0042 0.0021 0.0231 0.7480 0.1051
4 lightgbm Y_future_play_hours Future 7-day play hours 0.0076 0.0030 0.0046 0.0046 0.0184 0.8177 0.0093
5 xgboost Y_future_play_hours Future 7-day play hours 0.0062 0.0034 0.0028 0.0027 0.0138 0.8230 0.0075
model_family outcome target rmse mae r2
0 linear Y_future_interactions mediator 0.1015 0.0703 0.1878
1 linear Y_future_interactions outcome 96.6374 71.1050 0.7129
2 lightgbm Y_future_interactions mediator 0.0875 0.0629 0.3959
3 lightgbm Y_future_interactions outcome 66.7067 47.0941 0.8632
4 xgboost Y_future_interactions mediator 0.0870 0.0625 0.4026
5 xgboost Y_future_interactions outcome 66.0357 46.7289 0.8659
6 linear Y_future_play_hours mediator 0.1015 0.0703 0.1878
7 linear Y_future_play_hours outcome 0.3609 0.2428 0.4056
8 lightgbm Y_future_play_hours mediator 0.0875 0.0629 0.3959
9 lightgbm Y_future_play_hours outcome 0.2388 0.1672 0.7398
10 xgboost Y_future_play_hours mediator 0.0870 0.0625 0.4026
11 xgboost Y_future_play_hours outcome 0.2383 0.1694 0.7409

The model comparison is now cross-fitted. If the flexible models preserve the positive total effect and small mediated pathway, the earlier story is less likely to be an artifact of linear functional form.

11. Plot Cross-Fitted Model Comparison

This cell visualizes total, direct, and indirect effects across model families for the primary outcome. The plot uses the cross-fitted estimates from held-out users.

primary_ml_plot = ml_effects.query("outcome == @PRIMARY_OUTCOME").melt(
    id_vars=["model_family"],
    value_vars=["gcomp_total_effect", "natural_direct_effect", "natural_indirect_effect"],
    var_name="effect_type",
    value_name="estimate",
)
primary_ml_plot["effect_label"] = primary_ml_plot["effect_type"].map(
    {
        "gcomp_total_effect": "Total",
        "natural_direct_effect": "Direct",
        "natural_indirect_effect": "Indirect",
    }
)
primary_ml_plot["model_label"] = primary_ml_plot["model_family"].str.upper()

fig, ax = plt.subplots(figsize=(11, 6))
sns.barplot(data=primary_ml_plot, x="estimate", y="model_label", hue="effect_label", ax=ax)
ax.axvline(0, color="black", linewidth=1)
ax.set_title("cross-fitted ML Mediation Effect Comparison")
ax.set_xlabel("Effect on future 7-day interactions")
ax.set_ylabel("Model family")
plt.tight_layout()
plt.show()

This comparison is the main advanced-model result. Agreement across Linear, LightGBM, and XGBoost is more reassuring than any one model alone. This matters because the causal estimate is built from these supporting predictions, so weak nuisance behavior should make the downstream effect estimate less persuasive.

12. Compare Predictive Performance

This cell compares out-of-fold predictive performance for the mediator and outcome nuisance models. Stronger prediction alone is insufficient for a better causal estimate. Very weak prediction can make counterfactual estimates unstable.

performance_plot = ml_performance.copy()
performance_plot["model_label"] = performance_plot["model_family"].str.upper()
performance_plot["target_label"] = performance_plot["target"].str.title()
performance_plot["outcome_label"] = performance_plot["outcome"].map(
    {PRIMARY_OUTCOME: "Future interactions", SECONDARY_OUTCOME: "Future play hours"}
)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))
for ax, metric in zip(axes, ["rmse", "r2"]):
    sns.barplot(
        data=performance_plot.query("outcome == @PRIMARY_OUTCOME"),
        x=metric,
        y="model_label",
        hue="target_label",
        ax=ax,
    )
    ax.set_title(f"Primary Outcome Nuisance {metric.upper()}")
    ax.set_xlabel(metric.upper())
    ax.set_ylabel("Model family")
plt.tight_layout()
plt.show()

display(ml_performance.round(4))

model_family outcome target rmse mae r2
0 linear Y_future_interactions mediator 0.1015 0.0703 0.1878
1 linear Y_future_interactions outcome 96.6374 71.1050 0.7129
2 lightgbm Y_future_interactions mediator 0.0875 0.0629 0.3959
3 lightgbm Y_future_interactions outcome 66.7067 47.0941 0.8632
4 xgboost Y_future_interactions mediator 0.0870 0.0625 0.4026
5 xgboost Y_future_interactions outcome 66.0357 46.7289 0.8659
6 linear Y_future_play_hours mediator 0.1015 0.0703 0.1878
7 linear Y_future_play_hours outcome 0.3609 0.2428 0.4056
8 lightgbm Y_future_play_hours mediator 0.0875 0.0629 0.3959
9 lightgbm Y_future_play_hours outcome 0.2388 0.1672 0.7398
10 xgboost Y_future_play_hours mediator 0.0870 0.0625 0.4026
11 xgboost Y_future_play_hours outcome 0.2383 0.1694 0.7409

The performance table helps explain model behavior. If a flexible model changes the effect estimate but lacks held-out performance improvement, that change deserves caution. This matters because the causal estimate is built from these supporting predictions, so weak nuisance behavior should make the downstream effect estimate less persuasive.

Heterogeneity from Cross-Fitted Effects

This cell uses LightGBM’s row-level cross-fitted effects to summarize heterogeneity. The groups are based on pre-treatment information: prior activity, prior satisfaction, prior discovery, and user activity degree.

# Heterogeneity from cross-fitted effects.
heterogeneity_base = analysis_panel[[
    "user_id",
    "event_date",
    "prior_3day_interactions",
    "prior_3day_high_satisfaction_share",
    "prior_3day_discovery_candidate_share",
    "user_active_degree",
]].copy()

heterogeneity_base["prior_activity_group"] = pd.qcut(
    heterogeneity_base["prior_3day_interactions"].rank(method="first"),
    q=3,
    labels=["low prior activity", "medium prior activity", "high prior activity"],
)
heterogeneity_base["prior_satisfaction_group"] = pd.qcut(
    heterogeneity_base["prior_3day_high_satisfaction_share"].rank(method="first"),
    q=3,
    labels=["low prior satisfaction", "medium prior satisfaction", "high prior satisfaction"],
)
heterogeneity_base["prior_discovery_group"] = pd.qcut(
    heterogeneity_base["prior_3day_discovery_candidate_share"].rank(method="first"),
    q=3,
    labels=["low prior discovery", "medium prior discovery", "high prior discovery"],
)

lightgbm_row_effects = row_level_effects.query(
    "model_family == 'lightgbm' and outcome == @PRIMARY_OUTCOME"
).merge(
    heterogeneity_base,
    on=["user_id", "event_date"],
    how="left",
)

heterogeneity_rows = []
for segment_col in ["prior_activity_group", "prior_satisfaction_group", "prior_discovery_group", "user_active_degree"]:
    summary = (
        lightgbm_row_effects.groupby(segment_col, observed=True)
        .agg(
            user_days=("user_id", "size"),
            users=("user_id", "nunique"),
            total_effect=("ite_total", "mean"),
            direct_effect=("ite_direct", "mean"),
            indirect_effect=("ite_indirect", "mean"),
            mediator_shift=("m1_hat", "mean"),
        )
        .reset_index()
        .rename(columns={segment_col: "segment_value"})
    )
    summary["segment"] = segment_col
    heterogeneity_rows.append(summary)

heterogeneity_effects = pd.concat(heterogeneity_rows, ignore_index=True)
# Convert mediator_shift from average M1 to the segment-level treatment-induced shift.
for segment_col in ["prior_activity_group", "prior_satisfaction_group", "prior_discovery_group", "user_active_degree"]:
    mask = heterogeneity_effects["segment"].eq(segment_col)
    segment_shift = (
        lightgbm_row_effects.groupby(segment_col, observed=True)
        .apply(lambda g: (g["m1_hat"] - g["m0_hat"]).mean(), include_groups=False)
        .reset_index(name="true_mediator_shift")
        .rename(columns={segment_col: "segment_value"})
    )
    heterogeneity_effects.loc[mask, "mediator_shift"] = heterogeneity_effects.loc[mask].merge(
        segment_shift, on="segment_value", how="left"
    )["true_mediator_shift"].to_numpy()

display(heterogeneity_effects.round(4))
segment_value user_days users total_effect direct_effect indirect_effect mediator_shift segment
0 low prior activity 2733 133 2.1266 2.3286 -0.2021 0.0195 prior_activity_group
1 medium prior activity 2733 133 3.9690 4.0530 -0.0841 0.0177 prior_activity_group
2 high prior activity 2733 133 3.5253 3.4140 0.1112 0.0180 prior_activity_group
3 low prior satisfaction 2733 133 2.9903 2.8696 0.1208 0.0165 prior_satisfaction_group
4 medium prior satisfaction 2733 130 3.5907 3.7333 -0.1426 0.0158 prior_satisfaction_group
5 high prior satisfaction 2733 113 3.0398 3.1928 -0.1530 0.0229 prior_satisfaction_group
6 low prior discovery 2733 133 2.0592 2.1656 -0.1064 0.0184 prior_discovery_group
7 medium prior discovery 2733 133 3.3925 3.3729 0.0196 0.0166 prior_discovery_group
8 high prior discovery 2733 133 4.1691 4.2572 -0.0881 0.0201 prior_discovery_group
9 UNKNOWN 184 3 4.4181 4.5911 -0.1731 0.0241 user_active_degree
10 full_active 7133 115 3.1915 3.2485 -0.0570 0.0181 user_active_degree
11 high_active 882 15 3.0788 3.1241 -0.0453 0.0195 user_active_degree

The heterogeneity table asks where the discovery effect is largest. Because these are subgroup summaries of cross-fitted predictions, they are descriptive signals for product targeting.

14. Plot Heterogeneous Effects

This cell plots LightGBM total effects by segment. It focuses on total effects because those are the most stable quantities in the earlier robustness checks.

heterogeneity_plot = heterogeneity_effects.copy()
heterogeneity_plot["segment_label"] = heterogeneity_plot["segment"].str.replace("_", " ").str.title()
heterogeneity_plot["segment_value"] = heterogeneity_plot["segment_value"].astype(str)

fig, axes = plt.subplots(2, 2, figsize=(15, 10))
axes = axes.flatten()
for ax, segment in zip(axes, heterogeneity_plot["segment"].unique()):
    current = heterogeneity_plot.query("segment == @segment").copy()
    sns.barplot(data=current, x="total_effect", y="segment_value", color="steelblue", ax=ax)
    ax.axvline(0, color="black", linewidth=1)
    ax.set_title(segment.replace("_", " ").title())
    ax.set_xlabel("LightGBM total effect on future interactions")
    ax.set_ylabel("")
plt.tight_layout()
plt.show()

The heterogeneity plot makes the advanced notebook more product-relevant. It shows where the model expects discovery exposure to matter more or less. The teaching point is that weighting can reduce bias while also reducing usable information, so stability matters as much as the point estimate.

Feature Importance for Mediated Effects

This cell trains full-sample LightGBM and XGBoost nuisance models for feature importance only. These models support interpretation after the cross-fitted effect estimates above. The goal here is model auditability.

# Train full tree models for feature importance.
feature_importance_rows = []
full_design_m = treatment_design(X_covariates, A)
full_design_y = outcome_design(X_covariates, A, M)

importance_model_specs = [
    ("lightgbm", LGBMRegressor(
        n_estimators=120,
        learning_rate=0.05,
        num_leaves=24,
        subsample=0.85,
        colsample_bytree=0.85,
        random_state=RANDOM_SEED,
        n_jobs=1,
        verbose=-1,
    )),
    ("xgboost", XGBRegressor(
        n_estimators=120,
        max_depth=3,
        learning_rate=0.05,
        subsample=0.85,
        colsample_bytree=0.85,
        objective="reg:squarederror",
        tree_method="hist",
        random_state=RANDOM_SEED,
        n_jobs=1,
        verbosity=0,
    )),
]

for model_family, model in importance_model_specs:
    mediator_model = clone(model)
    outcome_model = clone(model)
    fit_with_optional_weight(mediator_model, full_design_m, M, sample_weight=weights)
    fit_with_optional_weight(outcome_model, full_design_y, Y, sample_weight=weights)

    for target_name, fitted_model, design in [
        ("mediator", mediator_model, full_design_m),
        ("outcome", outcome_model, full_design_y),
    ]:
        importances = getattr(fitted_model, "feature_importances_", None)
        if importances is None:
            continue
        current = pd.DataFrame(
            {
                "model_family": model_family,
                "target": target_name,
                "feature": design.columns,
                "importance": importances,
            }
        )
        current["importance_share"] = current["importance"] / current["importance"].sum()
        feature_importance_rows.append(current)

feature_importance = pd.concat(feature_importance_rows, ignore_index=True)
top_feature_importance = (
    feature_importance.sort_values(["model_family", "target", "importance"], ascending=[True, True, False])
    .groupby(["model_family", "target"], group_keys=False)
    .head(12)
    .reset_index(drop=True)
)

display(top_feature_importance.round(4))
model_family target feature importance importance_share
0 lightgbm mediator calendar_day_index 297.0000 0.1076
1 lightgbm mediator prior_3day_high_satisfaction_share 260.0000 0.0942
2 lightgbm mediator lag_1_high_satisfaction_share 249.0000 0.0902
3 lightgbm mediator prior_3day_discovery_candidate_share 187.0000 0.0678
4 lightgbm mediator onehot_feat8 147.0000 0.0533
5 lightgbm mediator lag_1_valid_play_share 137.0000 0.0496
6 lightgbm mediator prior_3day_valid_play_share 122.0000 0.0442
7 lightgbm mediator onehot_feat3 118.0000 0.0428
8 lightgbm mediator register_days 111.0000 0.0402
9 lightgbm mediator prior_3day_total_play_duration_sec 108.0000 0.0391
10 lightgbm mediator lag_1_total_play_duration_sec 103.0000 0.0373
11 lightgbm mediator A_high_discovery 101.0000 0.0366
12 lightgbm outcome calendar_day_index 592.0000 0.2145
13 lightgbm outcome onehot_feat8 187.0000 0.0678
14 lightgbm outcome prior_3day_discovery_candidate_share 180.0000 0.0652
15 lightgbm outcome prior_3day_interactions 157.0000 0.0569
16 lightgbm outcome register_days 148.0000 0.0536
17 lightgbm outcome onehot_feat7 126.0000 0.0457
18 lightgbm outcome prior_3day_high_satisfaction_share 121.0000 0.0438
19 lightgbm outcome onehot_feat3 120.0000 0.0435
20 lightgbm outcome follow_user_num 108.0000 0.0391
21 lightgbm outcome prior_3day_total_play_duration_sec 105.0000 0.0380
22 lightgbm outcome prior_3day_valid_play_share 103.0000 0.0373
23 lightgbm outcome fans_user_num 87.0000 0.0315
24 xgboost mediator prior_3day_high_satisfaction_share 0.1697 0.1697
25 xgboost mediator lag_1_high_satisfaction_share 0.0686 0.0686
26 xgboost mediator lag_1_valid_play_share 0.0570 0.0570
27 xgboost mediator prior_3day_valid_play_share 0.0436 0.0436
28 xgboost mediator lag_1_interactions 0.0432 0.0432
29 xgboost mediator onehot_feat11 0.0416 0.0416
30 xgboost mediator fans_user_num_range_1_10 0.0381 0.0381
31 xgboost mediator user_active_degree_high_active 0.0330 0.0330
32 xgboost mediator prior_3day_active_day 0.0312 0.0312
33 xgboost mediator onehot_feat6 0.0262 0.0262
34 xgboost mediator lag_1_total_play_duration_sec 0.0261 0.0261
35 xgboost mediator A_high_discovery 0.0227 0.0227
36 xgboost outcome calendar_day_index 0.3093 0.3093
37 xgboost outcome prior_3day_active_day 0.1905 0.1905
38 xgboost outcome prior_3day_discovery_candidate_share 0.1517 0.1517
39 xgboost outcome prior_3day_interactions 0.0373 0.0373
40 xgboost outcome lag_1_interactions 0.0202 0.0202
41 xgboost outcome lag_1_discovery_candidate_share 0.0190 0.0190
42 xgboost outcome recent_activity_score 0.0169 0.0169
43 xgboost outcome user_active_degree_high_active 0.0161 0.0161
44 xgboost outcome prior_3day_total_play_duration_sec 0.0145 0.0145
45 xgboost outcome register_days_range_61_90 0.0141 0.0141
46 xgboost outcome fans_user_num 0.0136 0.0136
47 xgboost outcome onehot_feat14 0.0117 0.0117

The feature-importance audit helps reveal whether models are mostly using treatment, mediator, calendar time, prior activity, or profile signals. That matters because an advanced model can look impressive while leaning heavily on confounding proxies.

16. Plot Feature Importance

This cell plots the top features for the LightGBM nuisance models. LightGBM is used here because its importances are usually stable and easy to read.

importance_plot = top_feature_importance.query("model_family == 'lightgbm'").copy()
importance_plot["feature_label"] = importance_plot["feature"].str.replace("_", " ").str.slice(0, 45)
importance_plot["target_label"] = importance_plot["target"].str.title()

fig, axes = plt.subplots(1, 2, figsize=(16, 6))
for ax, target in zip(axes, ["mediator", "outcome"]):
    current = importance_plot.query("target == @target").sort_values("importance", ascending=True)
    sns.barplot(data=current, x="importance", y="feature_label", color="steelblue", ax=ax)
    ax.set_title(f"LightGBM {target.title()} Model Features")
    ax.set_xlabel("Feature importance")
    ax.set_ylabel("")
plt.tight_layout()
plt.show()

The feature plot is an audit layer for model behavior. It tells us what the prediction functions used, which is useful for debugging and for communicating model behavior.

Advanced Model Summary

This cell combines the linear g-computation result from notebook 04, SEM-style paths, and cross-fitted ML estimates into one comparison table. The table is the main evidence bridge between the simple and advanced estimators.

# Advanced model summary.
linear_reference = effect_summary.query(
    "outcome == @PRIMARY_OUTCOME and estimand in ['gcomp_total_effect', 'natural_direct_effect', 'natural_indirect_effect']"
).pivot_table(index="outcome", columns="estimand", values="estimate").reset_index()
linear_reference["model_family"] = "linear_reference_from_notebook_04"
linear_reference = linear_reference.rename(
    columns={
        "gcomp_total_effect": "total_effect",
        "natural_direct_effect": "direct_effect",
        "natural_indirect_effect": "indirect_effect",
    }
)[["model_family", "total_effect", "direct_effect", "indirect_effect"]]

sem_reference = pd.DataFrame(
    [
        {
            "model_family": "sem_style_path_model",
            "total_effect": sem_path_results.loc[0, "total_path_c_prime_plus_indirect"],
            "direct_effect": sem_path_results.loc[0, "c_prime_A_to_Y"],
            "indirect_effect": sem_path_results.loc[0, "indirect_alpha_beta"],
        }
    ]
)

ml_reference = ml_effects.query("outcome == @PRIMARY_OUTCOME").rename(
    columns={
        "gcomp_total_effect": "total_effect",
        "natural_direct_effect": "direct_effect",
        "natural_indirect_effect": "indirect_effect",
    }
)[["model_family", "total_effect", "direct_effect", "indirect_effect", "mediator_shift", "relative_total_effect"]]

advanced_model_summary = pd.concat(
    [linear_reference, sem_reference, ml_reference],
    ignore_index=True,
    sort=False,
)
advanced_model_summary["indirect_to_total_ratio"] = (
    advanced_model_summary["indirect_effect"] / advanced_model_summary["total_effect"].replace(0, np.nan)
)
advanced_model_summary["total_effect_positive"] = advanced_model_summary["total_effect"] > 0
advanced_model_summary["indirect_effect_small_abs_lt_5"] = advanced_model_summary["indirect_effect"].abs() < 5

display(advanced_model_summary.round(4))
model_family total_effect direct_effect indirect_effect mediator_shift relative_total_effect indirect_to_total_ratio total_effect_positive indirect_effect_small_abs_lt_5
0 linear_reference_from_notebook_04 37.2781 38.5858 -1.3077 NaN NaN -0.0351 True True
1 sem_style_path_model 37.3139 38.4311 -1.1172 NaN NaN -0.0299 True True
2 linear 37.3421 38.6464 -1.3043 0.0231 0.1156 -0.0349 True True
3 lightgbm 3.2069 3.2652 -0.0583 0.0184 0.0095 -0.0182 True True
4 xgboost 2.6829 2.7279 -0.0450 0.0138 0.0079 -0.0168 True True

The comparison table answers the advanced-model question directly. If the total effect is positive across model families and the indirect effect remains small, the notebook 04 result is strengthened.

18. Plot Advanced Model Summary

This cell plots total, direct, and indirect effects across the reference, SEM-style, and cross-fitted ML approaches.

summary_plot = advanced_model_summary.melt(
    id_vars=["model_family"],
    value_vars=["total_effect", "direct_effect", "indirect_effect"],
    var_name="effect_type",
    value_name="estimate",
)
summary_plot["effect_label"] = summary_plot["effect_type"].map(
    {
        "total_effect": "Total",
        "direct_effect": "Direct",
        "indirect_effect": "Indirect",
    }
)
summary_plot["model_label"] = summary_plot["model_family"].str.replace("_", " ").str.title()

fig, ax = plt.subplots(figsize=(13, 7))
sns.barplot(data=summary_plot, x="estimate", y="model_label", hue="effect_label", ax=ax)
ax.axvline(0, color="black", linewidth=1)
ax.set_title("Advanced Model Comparison for Discovery Mediation")
ax.set_xlabel("Effect on future 7-day interactions")
ax.set_ylabel("Model approach")
plt.tight_layout()
plt.show()

This final figure is the high-level advanced-model takeaway. It lets a reader see whether the main story depends on the modeling approach. The decomposition is useful only if the pathway labels are kept clear, since direct, indirect, and total effects answer different operational questions.

The key comparison is between the simple mediation estimates, SEM-style paths, and cross-fitted ML estimates. This keeps the interpretation focused on whether the mediation story survives more flexible modeling choices.

Takeaways and Next Step

This notebook added advanced model checks to the discovery-quality mediation workflow:

  • SEM-style path modeling gives a compact path-coefficient version of the mediation story.
  • Cross-fitted Linear, LightGBM, and XGBoost nuisance models test whether the decomposition survives nonlinear prediction functions.
  • Heterogeneity summaries show where discovery exposure appears more or less valuable.
  • Feature-importance tables audit which variables the tree-based nuisance models rely on.
  • The final comparison table shows whether the main result is stable across modeling approaches.

Together, the simple mediation estimates, robustness checks, and advanced-model comparisons provide the evidence record for the discovery-quality conclusion.