01: KuaiRec Sequence EDA for Long-Term Causal Effects

This notebook starts the Long-Term Causal Effects lab. The central problem is a common tension in recommender systems. A recommendation policy can increase immediate engagement while still hurting longer-term user value. A short video feed, news feed, or streaming homepage may learn to maximize near-term clicks, watch time, or completion rate. Those metrics are useful. They also leave a more strategic data science question. Does the exposure pattern caused by the recommender improve future retention, future engagement, and sustained long-run user value?

This is a causal question because normal logs assign content exposure through platform and user behavior. A user sees videos because the platform selected them, because the user has a history, because the user has preferences, and because prior engagement changes what the recommender does next. If we compare users who received high-engagement exposures against users with lower exposure, the difference may reflect the recommender’s targeting logic.

The specific problem handled here is the sequential causal inference setup for KuaiRec.

This notebook prepares the panel structure needed for later causal estimation. It inspects whether KuaiRec has enough repeated user activity, date coverage, treatment variation, and future outcomes to support later notebooks on marginal structural models and g-computation. The practical question is whether this dataset can support a credible long-term causal effects project, and what the treatment and outcome definitions should look like.

Project Framing

What You Will Build

This project builds a sequential causal workflow for long-term effects in recommendation systems. You will reorganize KuaiRec interactions into user-day records, define short-term exposure histories and future engagement outcomes, then estimate long-term effects with weighting, marginal structural models, g-computation, and doubly robust methods.

How To Use This Project

Work through the notebooks in order. This first notebook builds the panel and explains the user-time structure. Later notebooks define the estimand, handle time-varying confounding, estimate stabilized weights, compare longitudinal estimators, add doubly robust estimation, and study heterogeneous effects.

Data Source And Scope

The analysis uses KuaiRec interaction logs. KuaiRec is useful here because it supports dense user-video evaluation, which helps construct longitudinal user-day summaries. The project remains observational, so the main teaching point is how sequential causal assumptions, diagnostics, and sensitivity analysis shape long-term recommendation claims.

Dataset and Sequential Design Context

This project uses KuaiRec interaction logs organized into a user-day panel. The raw data contain user-video interactions, watch behavior, user features, item metadata, and item daily popularity information.

The data come from a real observational sequential recommendation log. Daily exposure was created by the recommender system, so assignment must be treated as time-varying and confounded. The treatment is a constructed high-watch-exposure day, the primary outcome is future seven-day engagement, and lagged user behavior is both a predictor of treatment and a predictor of future outcomes.

The project studies a sequential causal question: whether short-term exposure is associated with later engagement after adjusting for observed history. Marginal structural models, g-computation, doubly robust estimation, overlap diagnostics, and heterogeneity checks are used to decide whether the evidence is credible enough to motivate a future online test.

Role of this notebook. This notebook checks whether KuaiRec can support a user-day sequential causal design by studying repeated activity, timing, treatment variation, and future outcome availability.

Mathematical Setup

This project uses panel-style user-day data. Let (i) index users and (t) index days. A short-term high-watch exposure treatment is (A_{it}), and a future engagement outcome is (Y_{i,t+h}). The observed history before treatment is

\[ H_{it}=\{X_i, A_{i,t-1}, Y_{i,t-1}, \ldots\}. \]

The causal question asks whether changing exposure would change future engagement. It is whether changing exposure at time (t) would change a future outcome:

\[ \tau_h=\mathbb{E}\left[Y_{i,t+h}(1)-Y_{i,t+h}(0)\right]. \]

The EDA checks whether the panel has enough repeated observations, treatment variation, and outcome variation to make that question analyzable.

Lab Roadmap

This notebook follows a causal data-understanding path. First, we load KuaiRec directly from the nested zip archive while keeping the full dataset compressed. Then we inspect the raw interaction fields, user metadata, item category metadata, and item daily popularity features. After that, we aggregate interactions into a user-day panel, because long-term causal effects require a time-indexed panel.

The most important output of the notebook is a reusable user-day table. Later notebooks can use this table to define treatment histories, estimate inverse probability weights, fit marginal structural models, and compare g-computation style estimates.

Data Fields and Column Guide

This project uses KuaiRec as a sequential recommendation log. The raw data is spread across interaction, user, item-category, and item-daily-feature tables. The field guide below explains the columns before any analysis code runs, so the rest of the notebook is easier to read.

User-Video Interaction Log

This is the main event-level table. Each row represents a user-video watch event.

Column Meaning How it matters for this project
user_id Unique user identifier. Defines the panel unit. Long-term causal effects are studied by following each user over time.
video_id Unique video identifier. Identifies the item consumed or exposed in the recommendation environment.
play_duration Amount of time the user played the video, stored in milliseconds. Short-term engagement signal. Later aggregated into daily play duration and used to define future outcomes.
video_duration Length of the video, stored in milliseconds. Item context and denominator for watch-ratio calculations.
time Human-readable event time string. Used as a diagnostic event timestamp; the notebook also parses the Unix timestamp field.
date Calendar date of the event, encoded like 20200705. Becomes the daily time index for the user-day panel.
timestamp Unix timestamp of the event in seconds. Used to reconstruct event ordering within users.
watch_ratio play_duration / video_duration. Values above 1 can occur when users rewatch or exceed the nominal video length. Core short-term engagement measure. Used to define high-watch exposure candidates and daily engagement summaries.

User Metadata

This table contains baseline user attributes and anonymized features. These columns can be used as baseline confounders because user characteristics may affect both what the recommender shows and how the user behaves later.

Column Meaning How it matters for this project
user_id Unique user identifier. Join key from interaction rows to user-level attributes.
user_active_degree Categorical user activity segment such as high-active or full-active. Baseline activity segment; likely related to future retention and exposure patterns.
is_lowactive_period Indicator for whether the user is in a low-activity period. Baseline or period-level activity state. Useful for understanding heterogeneous retention risk.
is_live_streamer Indicator for whether the user is a live streamer. User role/context feature that may affect content preferences and future behavior.
is_video_author Indicator for whether the user authors videos. User role/context feature; creators may behave differently from pure consumers.
follow_user_num Number of users this user follows. Social graph intensity; possible proxy for platform embeddedness.
follow_user_num_range Binned range version of follow_user_num. Categorical version of follow count for modeling or stratified summaries.
fans_user_num Number of fans/followers the user has. Popularity or creator-context proxy.
fans_user_num_range Binned range version of fans_user_num. Categorical version of fan count.
friend_user_num Number of friends connected to the user. Social connectedness proxy; may predict retention.
friend_user_num_range Binned range version of friend_user_num. Categorical version of friend count.
register_days Number of days since registration. User tenure; important baseline confounder for future engagement.
register_days_range Binned range version of register_days. Categorical tenure feature.
onehot_feat0 to onehot_feat17 Anonymized one-hot user features supplied by KuaiRec. Potential baseline covariates. They are useful for prediction and adjustment. Their substantive meaning is anonymized in the public release.

Video Category Metadata

This table maps videos to one or more category identifiers.

Column Meaning How it matters for this project
video_id Unique video identifier. Join key from interaction rows to video metadata.
feat String representation of a list of category ids, such as [27, 9]. Describes item content category. Later parsed into category count and first-category diagnostics.

Daily Video Popularity and Engagement Context

The full table has many daily item metrics. This notebook loads a focused subset that is directly useful for sequential recommendation diagnostics. These features can become item-day context variables because item popularity may affect both recommendation exposure and downstream user behavior.

Column Meaning How it matters for this project
video_id Unique video identifier. Join key to connect daily item context to consumed videos.
date Calendar date for the item-day metrics. Aligns item popularity with the same daily panel used for users.
show_cnt Number of times the video was shown. Visibility/popularity proxy. A highly shown item may be more likely to appear in recommendation logs.
show_user_num Number of users who were shown the video. User-level reach of the item on that day.
play_cnt Number of plays. Daily item consumption volume.
play_user_num Number of users who played the video. Daily user reach among players.
complete_play_cnt Number of complete plays. Daily completion signal for the item.
like_cnt Number of likes. Positive feedback signal for the item.
comment_cnt Number of comments. Deeper engagement signal for the item.
share_cnt Number of shares. Viral or social engagement signal for the item.
collect_cnt Number of collections/saves. Longer-intent engagement signal for the item.

Derived Columns Created Later in This Lab

The notebook also creates derived fields after loading the raw data. These fields are central to the causal setup.

Derived column family Meaning
event_date, event_time, event_timestamp Cleaned time fields used to order events and build daily panels.
play_duration_sec, video_duration_sec Human-readable duration fields converted from milliseconds to seconds.
high_watch, complete_or_rewatch Interaction-level engagement indicators based on watch-ratio thresholds.
active_day, interactions, total_play_duration_sec, avg_watch_ratio User-day summaries used to describe daily state.
lag_1_*, prior_3day_* Pre-treatment history variables used as time-varying confounder candidates.
next_day_*, future_3day_*, future_7day_* Future outcome variables used to study longer-term engagement.
treatment_high_intensity, treatment_high_watch_exposure First-pass daily treatment definitions used to inspect treatment variation and confounding.

Setup

The first code cell imports the libraries used throughout the notebook. The plotting settings are kept simple and readable because this notebook is mostly about understanding temporal structure. We also make pandas show enough rows and columns for compact diagnostic tables.

from io import BytesIO
from pathlib import Path
from zipfile import ZipFile
import ast
import warnings

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from IPython.display import display

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

pd.set_option("display.max_columns", 80)
pd.set_option("display.max_rows", 80)
pd.set_option("display.float_format", lambda value: f"{value:,.4f}")

sns.set_theme(style="whitegrid", context="notebook")

The notebook environment is now ready for sequential EDA: pandas handles the panel operations, seaborn and matplotlib handle visual checks, and the display settings make the wide diagnostic tables readable. The next step is to locate the project data in a way that works whether the notebook is run from the repository root or from inside the notebook folder.

Locate the Project and Data

This notebook may be run from the repository root or from inside the notebooks/projects/project_3_long_term_causal_effects folder. The next cell searches upward until it finds the KuaiRec archive. Keeping this path logic inside the notebook makes it easier to rerun from Jupyter while avoiding manual directory changes.

KUAI_REC_ZIP_RELATIVE_PATH = Path("data/Kuairec/18164998.zip")

candidate_roots = [Path.cwd(), *Path.cwd().parents]
PROJECT_ROOT = next(
    (path for path in candidate_roots if (path / KUAI_REC_ZIP_RELATIVE_PATH).exists()),
    None,
)

if PROJECT_ROOT is None:
    raise FileNotFoundError(
        f"Could not find {KUAI_REC_ZIP_RELATIVE_PATH}. Run this notebook from inside the ranking_sys project."
    )

KUAI_REC_ZIP = PROJECT_ROOT / KUAI_REC_ZIP_RELATIVE_PATH
PROCESSED_DIR = PROJECT_ROOT / "data" / "processed"
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)

print(f"Project root: {PROJECT_ROOT}")
print(f"KuaiRec archive: {KUAI_REC_ZIP}")
print(f"Processed output folder: {PROCESSED_DIR}")

The printed paths confirm that the notebook found the project root, the KuaiRec archive, and the processed-data folder. With those locations fixed, the next cell can inspect the raw archive contents while avoiding hard-coded local machine paths.

Inspect the Nested Archive

The downloaded file is an outer archive that contains a second archive named KuaiRec.zip. The interaction and metadata CSV files live inside that inner archive. This cell lists the relevant files so we know exactly what data sources are available before reading any large tables.

# Inspect the nested archive.
with ZipFile(KUAI_REC_ZIP) as outer_zip:
    outer_inventory = pd.DataFrame(
        [
            {
                "member": member.filename,
                "size_mb": member.file_size / 1_000_000,
            }
            for member in outer_zip.infolist()
        ]
    )
    inner_zip_bytes = outer_zip.read("KuaiRec.zip")

with ZipFile(BytesIO(inner_zip_bytes)) as inner_zip:
    inner_inventory = pd.DataFrame(
        [
            {
                "member": member.filename,
                "size_mb": member.file_size / 1_000_000,
            }
            for member in inner_zip.infolist()
            if member.filename.startswith("KuaiRec 2.0/data/")
        ]
    )

print("Outer archive contents")
display(outer_inventory.sort_values("size_mb", ascending=False))

print("Inner KuaiRec data files")
display(inner_inventory.sort_values("member"))
Outer archive contents
member size_mb
0 KuaiRec.zip 431.9649
1 kuairec_caption_category.csv 1.9646
2 video_raw_categories_multi.csv 1.7245
3 user_features_raw.csv 1.5416
Inner KuaiRec data files
member size_mb
0 KuaiRec 2.0/data/ 0.0000
1 KuaiRec 2.0/data/big_matrix.csv 1,083.5212
2 KuaiRec 2.0/data/item_categories.csv 0.1131
3 KuaiRec 2.0/data/item_daily_features.csv 85.8552
4 KuaiRec 2.0/data/kuairec_caption_category.csv 1.9646
5 KuaiRec 2.0/data/small_matrix.csv 406.1558
6 KuaiRec 2.0/data/social_network.csv 0.0069
7 KuaiRec 2.0/data/user_features.csv 0.7442

The archive inventory shows that KuaiRec is nested. The downloaded file contains an inner KuaiRec.zip, and the useful CSV files live inside that inner archive. This confirms that the notebook should read the data directly from the nested zip.

Choose the Tables and Sample Size

KuaiRec contains very large interaction matrices. For a first EDA notebook, we use a deterministic sample from small_matrix.csv. This is enough to study repeated users, daily activity, candidate treatments, and future outcomes while keeping the notebook fast enough to rerun interactively.

The sample is a development-friendly slice used to design the causal panel. Later notebooks can increase the sample size or switch to a full extraction once the logic is stable.

SMALL_MATRIX_MEMBER = "KuaiRec 2.0/data/small_matrix.csv"
USER_FEATURES_MEMBER = "KuaiRec 2.0/data/user_features.csv"
ITEM_CATEGORIES_MEMBER = "KuaiRec 2.0/data/item_categories.csv"
ITEM_DAILY_FEATURES_MEMBER = "KuaiRec 2.0/data/item_daily_features.csv"

SAMPLE_ROWS = 300_000
ITEM_DAILY_SAMPLE_ROWS = 150_000

print(f"Interaction rows to read from small_matrix.csv: {SAMPLE_ROWS:,}")
print(f"Item daily feature rows to read: {ITEM_DAILY_SAMPLE_ROWS:,}")
Interaction rows to read from small_matrix.csv: 300,000
Item daily feature rows to read: 150,000

The selected files cover the ingredients needed for the project: interaction events, user metadata, item categories, and daily item context. The sample sizes keep this first notebook fast while still preserving a multi-user, multi-day sequential structure for causal design work.

Load and Normalize the Interaction Sample

The interaction table is the core event log. Each row records a user watching a video at a time, with the play duration, video duration, date, timestamp, and watch ratio. The next cell reads the sample and creates cleaner time columns.

The durations appear to be stored in milliseconds, so the cell also creates second-based versions that are easier to interpret. The watch_ratio is the observed play duration divided by video duration. Values above 1 can happen when users rewatch, loop, or otherwise spend longer than the nominal video duration.

with ZipFile(BytesIO(inner_zip_bytes)) as inner_zip:
    with inner_zip.open(SMALL_MATRIX_MEMBER) as file:
        interactions = pd.read_csv(file, nrows=SAMPLE_ROWS)

# Convert KuaiRec's numeric date field into a proper datetime date.
date_numeric = pd.to_numeric(interactions["date"], errors="coerce").round().astype("Int64")
interactions["event_date"] = pd.to_datetime(
    date_numeric.astype("string"),
    format="%Y%m%d",
    errors="coerce",
)

# The timestamp column is Unix time in seconds. It is the most reliable event-time field.
interactions["event_timestamp"] = pd.to_datetime(
    pd.to_numeric(interactions["timestamp"], errors="coerce"),
    unit="s",
    errors="coerce",
)

# Keep a parsed version of the original time field as a backup and diagnostic.
interactions["event_time_from_text"] = pd.to_datetime(interactions["time"], errors="coerce")
interactions["event_time"] = interactions["event_time_from_text"].fillna(interactions["event_timestamp"])

# Add human-readable duration columns. The original millisecond fields are retained.
interactions["play_duration_sec"] = interactions["play_duration"] / 1_000
interactions["video_duration_sec"] = interactions["video_duration"] / 1_000
interactions["watch_ratio_capped_5"] = interactions["watch_ratio"].clip(lower=0, upper=5)

interactions = interactions.sort_values(["user_id", "event_time", "video_id"]).reset_index(drop=True)

print(f"Loaded interaction sample shape: {interactions.shape}")
display(interactions.head())
Loaded interaction sample shape: (300000, 15)
user_id video_id play_duration video_duration time date timestamp watch_ratio event_date event_timestamp event_time_from_text event_time play_duration_sec video_duration_sec watch_ratio_capped_5
0 14 148 4381 6067 2020-07-05 05:27:48.378 20,200,705.0000 1,593,898,068.3780 0.7221 2020-07-05 2020-07-04 21:27:48.378000021 2020-07-05 05:27:48.378 2020-07-05 05:27:48.378 4.3810 6.0670 0.7221
1 14 183 11635 6100 2020-07-05 05:28:00.057 20,200,705.0000 1,593,898,080.0570 1.9074 2020-07-05 2020-07-04 21:28:00.056999922 2020-07-05 05:28:00.057 2020-07-05 05:28:00.057 11.6350 6.1000 1.9074
2 14 3649 22422 10867 2020-07-05 05:29:09.479 20,200,705.0000 1,593,898,149.4790 2.0633 2020-07-05 2020-07-04 21:29:09.479000092 2020-07-05 05:29:09.479 2020-07-05 05:29:09.479 22.4220 10.8670 2.0633
3 14 5262 4479 7908 2020-07-05 05:30:43.285 20,200,705.0000 1,593,898,243.2850 0.5664 2020-07-05 2020-07-04 21:30:43.285000086 2020-07-05 05:30:43.285 2020-07-05 05:30:43.285 4.4790 7.9080 0.5664
4 14 8234 4602 11000 2020-07-05 05:35:43.459 20,200,705.0000 1,593,898,543.4590 0.4184 2020-07-05 2020-07-04 21:35:43.459000111 2020-07-05 05:35:43.459 2020-07-05 05:35:43.459 4.6020 11.0000 0.4184

The interaction sample is now sorted and enriched with clean event dates, timestamps, seconds-based duration fields, and a capped watch-ratio helper for plots. This makes the raw event log usable for both ordinary EDA and later user-day aggregation.

Load User, Item, and Daily Item Metadata

The interaction log is insufficient for causal analysis because treatment assignment depends on user and item context. The next cell loads three metadata tables:

  • User-level characteristics such as activity degree, follower counts, registration age, and anonymized one-hot features.
  • Video category identifiers.
  • Daily item popularity and engagement counts, which are useful proxies for how popular or visible an item was on a given day.

These features are candidates for later treatment and outcome models.

# Load user, item, and daily item metadata.
with ZipFile(BytesIO(inner_zip_bytes)) as inner_zip:
    with inner_zip.open(USER_FEATURES_MEMBER) as file:
        user_features = pd.read_csv(file)

    with inner_zip.open(ITEM_CATEGORIES_MEMBER) as file:
        item_categories = pd.read_csv(file)

    daily_usecols = [
        "video_id",
        "date",
        "show_cnt",
        "show_user_num",
        "play_cnt",
        "play_user_num",
        "complete_play_cnt",
        "like_cnt",
        "comment_cnt",
        "share_cnt",
        "collect_cnt",
    ]
    with inner_zip.open(ITEM_DAILY_FEATURES_MEMBER) as file:
        item_daily = pd.read_csv(file, usecols=daily_usecols, nrows=ITEM_DAILY_SAMPLE_ROWS)

# Convert category strings such as "[27, 9]" into counts and first-category diagnostics.
def parse_category_list(value):
    """
    Parse KuaiRec category fields into Python lists.
    
    Idea
    ----
    The raw metadata stores category information in compact string form, and this helper normalizes it for item-level grouping and discovery metrics.
    
    Parameters
    ----------
    value : object
        Project-specific input named `value` used by this helper.
    
    Returns
    -------
    list
        List of parsed category identifiers or labels.
    """
    try:
        parsed = ast.literal_eval(value)
    except (ValueError, SyntaxError):
        return []
    return parsed if isinstance(parsed, list) else []

category_lists = item_categories["feat"].map(parse_category_list)
item_categories["category_count"] = category_lists.map(len)
item_categories["first_category"] = category_lists.map(lambda values: values[0] if values else np.nan)

item_daily["event_date"] = pd.to_datetime(item_daily["date"].astype("string"), format="%Y%m%d", errors="coerce")
item_daily["item_play_rate"] = np.where(
    item_daily["show_cnt"] > 0,
    item_daily["play_cnt"] / item_daily["show_cnt"],
    np.nan,
)
item_daily["item_like_rate_per_play"] = np.where(
    item_daily["play_cnt"] > 0,
    item_daily["like_cnt"] / item_daily["play_cnt"],
    np.nan,
)

print(f"User features shape: {user_features.shape}")
print(f"Item categories shape: {item_categories.shape}")
print(f"Item daily feature sample shape: {item_daily.shape}")
User features shape: (7176, 31)
Item categories shape: (10728, 4)
Item daily feature sample shape: (150000, 14)

The metadata tables loaded successfully and can be joined to the interaction log by user_id, video_id, and date. This matters because long-term causal modeling needs context: users and items have different baseline histories, and popularity or user tenure may influence both exposure and future engagement.

Raw Column Dictionary

Before aggregating anything, it helps to translate the key raw fields into causal language. This cell creates a compact dictionary for the columns we will use most often. This helps readers see the connection between raw logging fields and causal design components.

# Prepare raw column dictionary.
column_dictionary = pd.DataFrame(
    [
        {
            "table": "small_matrix",
            "column": "user_id",
            "meaning": "User identifier. This becomes the panel unit.",
            "causal_role": "Unit identifier",
        },
        {
            "table": "small_matrix",
            "column": "video_id",
            "meaning": "Recommended or consumed video identifier.",
            "causal_role": "Exposure content identifier",
        },
        {
            "table": "small_matrix",
            "column": "play_duration",
            "meaning": "Observed play duration in milliseconds.",
            "causal_role": "Short-term engagement outcome and future-state input",
        },
        {
            "table": "small_matrix",
            "column": "video_duration",
            "meaning": "Video duration in milliseconds.",
            "causal_role": "Item context and denominator for watch ratio",
        },
        {
            "table": "small_matrix",
            "column": "watch_ratio",
            "meaning": "Play duration divided by video duration; values above 1 indicate rewatch or over-completion.",
            "causal_role": "Short-term engagement and candidate exposure-quality measure",
        },
        {
            "table": "small_matrix",
            "column": "event_date",
            "meaning": "Calendar date derived from KuaiRec's date field.",
            "causal_role": "Time index for the user-day panel",
        },
        {
            "table": "user_features",
            "column": "user_active_degree",
            "meaning": "Categorical user activity segment supplied by KuaiRec.",
            "causal_role": "Baseline covariate and confounder candidate",
        },
        {
            "table": "item_daily_features",
            "column": "show_cnt / play_cnt",
            "meaning": "Daily item visibility and play counts.",
            "causal_role": "Item popularity context and confounder candidate",
        },
    ]
)

display(column_dictionary)
table column meaning causal_role
0 small_matrix user_id User identifier. This becomes the panel unit. Unit identifier
1 small_matrix video_id Recommended or consumed video identifier. Exposure content identifier
2 small_matrix play_duration Observed play duration in milliseconds. Short-term engagement outcome and future-state...
3 small_matrix video_duration Video duration in milliseconds. Item context and denominator for watch ratio
4 small_matrix watch_ratio Play duration divided by video duration; value... Short-term engagement and candidate exposure-q...
5 small_matrix event_date Calendar date derived from KuaiRec's date field. Time index for the user-day panel
6 user_features user_active_degree Categorical user activity segment supplied by ... Baseline covariate and confounder candidate
7 item_daily_features show_cnt / play_cnt Daily item visibility and play counts. Item popularity context and confounder candidate

The column dictionary translates raw logging fields into causal roles. That translation is important for the rest of the notebook because it decides which fields can become units, treatments, covariates, and outcomes.

Basic Shape and Coverage

The first data-readiness question is whether the sample contains repeated observations over time. Long-term causal effects require enough users, enough dates, and enough future observations. The next cell summarizes the main coverage metrics for the interaction sample.

coverage_summary = pd.DataFrame(
    [
        {"metric": "interaction_rows", "value": len(interactions)},
        {"metric": "unique_users", "value": interactions["user_id"].nunique()},
        {"metric": "unique_videos", "value": interactions["video_id"].nunique()},
        {"metric": "unique_event_dates", "value": interactions["event_date"].nunique()},
        {"metric": "first_event_date", "value": interactions["event_date"].min()},
        {"metric": "last_event_date", "value": interactions["event_date"].max()},
        {"metric": "first_event_time", "value": interactions["event_time"].min()},
        {"metric": "last_event_time", "value": interactions["event_time"].max()},
    ]
)

display(coverage_summary)
metric value
0 interaction_rows 300000
1 unique_users 91
2 unique_videos 3327
3 unique_event_dates 63
4 first_event_date 2020-07-05 00:00:00
5 last_event_date 2020-09-05 00:00:00
6 first_event_time 2020-07-05 00:01:03.816000
7 last_event_time 2020-09-05 23:11:44.456000

The coverage table confirms that the sample contains repeated observations across users, videos, and calendar dates. Because the data spans multiple days per user, it is suitable for constructing histories and future outcomes.

Example Rows After Cleaning

This cell shows a few cleaned rows after date parsing and duration normalization. The goal is to verify that the event time, event date, duration, and watch-ratio fields look coherent before we build a panel from them.

preview_columns = [
    "user_id",
    "video_id",
    "event_date",
    "event_time",
    "play_duration_sec",
    "video_duration_sec",
    "watch_ratio",
]

display(interactions[preview_columns].head(10))
user_id video_id event_date event_time play_duration_sec video_duration_sec watch_ratio
0 14 148 2020-07-05 2020-07-05 05:27:48.378 4.3810 6.0670 0.7221
1 14 183 2020-07-05 2020-07-05 05:28:00.057 11.6350 6.1000 1.9074
2 14 3649 2020-07-05 2020-07-05 05:29:09.479 22.4220 10.8670 2.0633
3 14 5262 2020-07-05 2020-07-05 05:30:43.285 4.4790 7.9080 0.5664
4 14 8234 2020-07-05 2020-07-05 05:35:43.459 4.6020 11.0000 0.4184
5 14 6789 2020-07-05 2020-07-05 05:36:00.773 8.6070 13.2670 0.6488
6 14 1963 2020-07-05 2020-07-05 05:36:47.741 8.6130 9.5900 0.8981
7 14 175 2020-07-05 2020-07-05 05:49:27.965 11.6400 46.5140 0.2502
8 14 1973 2020-07-05 2020-07-05 05:49:41.762 4.5720 7.4000 0.6178
9 14 171 2020-07-05 2020-07-05 05:57:26.581 8.5180 5.2170 1.6327

The preview rows show that the cleaned event date, event time, durations, and watch ratio are aligned at the interaction level. Since these fields look coherent, the next checks can focus on missingness and distributional shape.

Missingness and Data Type Checks

Missing values matter because causal estimators usually require complete treatment, outcome, and confounder histories. This cell computes missing rates and shows the cleaned data types. A high missing rate in a key time or outcome column would be a blocker for later notebooks.

missingness = (
    interactions.isna()
    .mean()
    .sort_values(ascending=False)
    .rename("missing_rate")
    .reset_index()
    .rename(columns={"index": "column"})
)

dtypes = (
    interactions.dtypes.astype(str)
    .rename("dtype")
    .reset_index()
    .rename(columns={"index": "column"})
)

missingness_with_types = missingness.merge(dtypes, on="column", how="left")

display(missingness_with_types)
column missing_rate dtype
0 timestamp 0.0365 float64
1 date 0.0365 float64
2 time 0.0365 str
3 event_time 0.0365 datetime64[ns]
4 event_time_from_text 0.0365 datetime64[us]
5 event_timestamp 0.0365 datetime64[ns]
6 event_date 0.0365 datetime64[us]
7 video_duration 0.0000 int64
8 user_id 0.0000 int64
9 video_id 0.0000 int64
10 play_duration 0.0000 int64
11 watch_ratio 0.0000 float64
12 play_duration_sec 0.0000 float64
13 video_duration_sec 0.0000 float64
14 watch_ratio_capped_5 0.0000 float64

This check tells us whether any key variables would block later causal modeling. Low missingness in identifiers, timing fields, and engagement metrics means the sample can be safely aggregated into a daily panel with minimal hidden row loss.

Engagement Distribution Summary

Short-term engagement is the raw material for both candidate treatments and future outcomes. The next cell summarizes play duration, video duration, and watch ratio. The percentile view is important because watch-time data is usually skewed: a small number of very long or repeated watches can dominate averages.

engagement_summary = interactions[
    ["play_duration_sec", "video_duration_sec", "watch_ratio", "watch_ratio_capped_5"]
].describe(percentiles=[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99]).T

display(engagement_summary)
count mean std min 1% 5% 25% 50% 75% 95% 99% max
play_duration_sec 300,000.0000 8.7096 12.7322 0.0000 0.5640 1.9380 5.7090 7.6695 9.6900 15.7380 32.5791 1,502.2620
video_duration_sec 300,000.0000 14.4755 20.4464 3.0670 4.8850 5.9000 7.5220 9.5930 11.9340 44.8500 139.8770 315.0720
watch_ratio 300,000.0000 0.9191 1.5511 0.0000 0.0227 0.1017 0.4717 0.7720 1.1220 1.9767 3.7086 333.8360
watch_ratio_capped_5 300,000.0000 0.8802 0.6698 0.0000 0.0227 0.1017 0.4717 0.7720 1.1220 1.9767 3.7086 5.0000

The percentile table shows the skew typical of watch-time data. Watch ratios and play durations have long tails, so later plots and models should account for extreme values.

Visualize Short-Term Engagement

The plots below show the distribution of watch ratio and play duration. The watch-ratio plot is capped at 5 for readability, because extreme rewatch values can make the main distribution hard to see. This is only a plotting cap; the raw watch_ratio remains available.

# Visualize short-Term engagement.
fig, axes = plt.subplots(1, 2, figsize=(14, 4.5))

sns.histplot(
    data=interactions,
    x="watch_ratio_capped_5",
    bins=60,
    ax=axes[0],
    color="#2A6F97",
)
axes[0].axvline(0.8, color="black", linestyle="--", linewidth=1, label="0.8 threshold")
axes[0].axvline(1.0, color="darkred", linestyle="--", linewidth=1, label="1.0 threshold")
axes[0].set_title("Watch Ratio Distribution, Capped at 5")
axes[0].set_xlabel("Watch ratio")
axes[0].legend()

sns.histplot(
    data=interactions,
    x="play_duration_sec",
    bins=60,
    ax=axes[1],
    color="#5C946E",
)
axes[1].set_title("Play Duration Distribution")
axes[1].set_xlabel("Play duration, seconds")
axes[1].set_yscale("log")

plt.tight_layout()
plt.show()

The plots make the engagement skew easier to see. The watch-ratio thresholds around 0.8 and 1.0 are plausible anchors for high-watch and over-completion behavior, which motivates the candidate treatment definitions created later.

User and Item Repeated Measures

Sequential causal inference needs repeated observations for the same users. If every user appeared once, we would lack histories, lagged confounders, and future retention. The next cell summarizes how many interactions and active days each sampled user contributes, and how concentrated video consumption is across items.

# Prepare user and item repeated measures.
user_activity = (
    interactions.groupby("user_id")
    .agg(
        interactions=("video_id", "size"),
        unique_videos=("video_id", "nunique"),
        active_days=("event_date", "nunique"),
        first_date=("event_date", "min"),
        last_date=("event_date", "max"),
        avg_watch_ratio=("watch_ratio", "mean"),
        total_play_duration_sec=("play_duration_sec", "sum"),
    )
    .reset_index()
)

item_activity = (
    interactions.groupby("video_id")
    .agg(
        impressions=("user_id", "size"),
        unique_users=("user_id", "nunique"),
        avg_watch_ratio=("watch_ratio", "mean"),
        avg_play_duration_sec=("play_duration_sec", "mean"),
    )
    .reset_index()
    .sort_values("impressions", ascending=False)
)

print("User activity summary")
display(user_activity[["interactions", "unique_videos", "active_days", "avg_watch_ratio", "total_play_duration_sec"]].describe().T)

print("Most observed videos in the sample")
display(item_activity.head(10))
User activity summary
count mean std min 25% 50% 75% max
interactions 91.0000 3,296.7033 166.3290 1,729.0000 3,309.0000 3,315.0000 3,320.0000 3,326.0000
unique_videos 91.0000 3,296.7033 166.3290 1,729.0000 3,309.0000 3,315.0000 3,320.0000 3,326.0000
active_days 91.0000 60.8462 3.9831 31.0000 61.0000 62.0000 63.0000 63.0000
avg_watch_ratio 91.0000 0.9190 0.1953 0.6512 0.7941 0.8832 0.9841 2.0275
total_play_duration_sec 91.0000 28,712.8942 6,276.4357 13,711.2750 25,107.3275 28,289.4630 31,055.6880 63,349.6840
Most observed videos in the sample
video_id impressions unique_users avg_watch_ratio avg_play_duration_sec
31 186 91 91 1.1330 7.7613
30 183 91 91 1.6252 9.9136
0 103 91 91 0.7553 7.7798
1 109 91 91 1.0973 8.5228
2 120 91 91 1.9835 12.4414
3 122 91 91 0.8607 8.3776
4 128 91 91 0.7762 8.0989
5 130 91 91 0.8416 8.4340
29 180 91 91 0.8184 9.0079
28 179 91 91 1.2162 7.3785

The user and item summaries show how much repeated information exists per user and how concentrated consumption is across videos. Repeated user histories are the key ingredient for long-term causal effects, while item concentration reminds us that content popularity may be a confounder.

Plot Repeated User Activity

The next plots show whether users have enough repeated activity for a user-day panel. The left plot checks interaction volume per user. The right plot checks the number of active calendar days per user. More repeated days means more usable history for later marginal structural modeling.

fig, axes = plt.subplots(1, 2, figsize=(14, 4.5))

sns.histplot(data=user_activity, x="interactions", bins=40, ax=axes[0], color="#2A6F97")
axes[0].set_title("Interactions per User")
axes[0].set_xlabel("Interaction rows")

sns.histplot(data=user_activity, x="active_days", bins=30, ax=axes[1], color="#C07F00")
axes[1].set_title("Active Days per User")
axes[1].set_xlabel("Active calendar days")

plt.tight_layout()
plt.show()

The activity plots help verify that users contribute multiple interaction rows and active days. That supports the next move: aggregating events into user-day records so that each row can represent one time step in a longitudinal causal panel.

Build the Observed User-Day Table

The raw data is interaction-level. Long-term causal effects are easier to reason about at a user-day level. This cell aggregates each user’s activity on each observed day. It creates daily metrics such as total interactions, unique videos, total play duration, average watch ratio, and the share of watched videos that crossed a high-watch threshold.

The high-watch threshold is set at watch_ratio >= 0.8 as a first-pass operational definition. This still leaves the final treatment definition open. It is a transparent candidate that later notebooks can refine.

# Build the observed user-Day table.
HIGH_WATCH_THRESHOLD = 0.8
COMPLETE_OR_REWATCH_THRESHOLD = 1.0

interactions["high_watch"] = (interactions["watch_ratio"] >= HIGH_WATCH_THRESHOLD).astype(int)
interactions["complete_or_rewatch"] = (interactions["watch_ratio"] >= COMPLETE_OR_REWATCH_THRESHOLD).astype(int)

user_day_observed = (
    interactions.groupby(["user_id", "event_date"])
    .agg(
        interactions=("video_id", "size"),
        unique_videos=("video_id", "nunique"),
        total_play_duration_ms=("play_duration", "sum"),
        avg_play_duration_ms=("play_duration", "mean"),
        avg_video_duration_ms=("video_duration", "mean"),
        avg_watch_ratio=("watch_ratio", "mean"),
        high_watch_count=("high_watch", "sum"),
        complete_or_rewatch_count=("complete_or_rewatch", "sum"),
    )
    .reset_index()
)

user_day_observed["total_play_duration_sec"] = user_day_observed["total_play_duration_ms"] / 1_000
user_day_observed["avg_play_duration_sec"] = user_day_observed["avg_play_duration_ms"] / 1_000
user_day_observed["avg_video_duration_sec"] = user_day_observed["avg_video_duration_ms"] / 1_000
user_day_observed["high_watch_share"] = user_day_observed["high_watch_count"] / user_day_observed["interactions"]
user_day_observed["complete_or_rewatch_share"] = user_day_observed["complete_or_rewatch_count"] / user_day_observed["interactions"]

user_day_observed = user_day_observed.sort_values(["user_id", "event_date"]).reset_index(drop=True)

print(f"Observed user-day rows: {len(user_day_observed):,}")
display(user_day_observed.head(10))
Observed user-day rows: 5,537
user_id event_date interactions unique_videos total_play_duration_ms avg_play_duration_ms avg_video_duration_ms avg_watch_ratio high_watch_count complete_or_rewatch_count total_play_duration_sec avg_play_duration_sec avg_video_duration_sec high_watch_share complete_or_rewatch_share
0 14 2020-07-05 26 26 240975 9,268.2692 10,187.6538 1.0845 15 12 240.9750 9.2683 10.1877 0.5769 0.4615
1 14 2020-07-06 23 23 248344 10,797.5652 14,615.2174 1.0640 12 10 248.3440 10.7976 14.6152 0.5217 0.4348
2 14 2020-07-07 78 78 655489 8,403.7051 13,529.6410 0.8415 36 27 655.4890 8.4037 13.5296 0.4615 0.3462
3 14 2020-07-08 22 22 201901 9,177.3182 12,657.1818 0.9828 11 9 201.9010 9.1773 12.6572 0.5000 0.4091
4 14 2020-07-09 55 55 485039 8,818.8909 12,841.6727 0.8619 20 15 485.0390 8.8189 12.8417 0.3636 0.2727
5 14 2020-07-10 52 52 606244 11,658.5385 17,735.4038 1.1380 30 21 606.2440 11.6585 17.7354 0.5769 0.4038
6 14 2020-07-11 32 32 284747 8,898.3438 13,858.9688 0.9337 16 10 284.7470 8.8983 13.8590 0.5000 0.3125
7 14 2020-07-12 42 42 337918 8,045.6667 13,215.6429 0.7986 18 11 337.9180 8.0457 13.2156 0.4286 0.2619
8 14 2020-07-13 46 46 502145 10,916.1957 13,244.0000 1.0389 24 19 502.1450 10.9162 13.2440 0.5217 0.4130
9 14 2020-07-14 42 42 337489 8,035.4524 14,600.6190 0.9892 23 16 337.4890 8.0355 14.6006 0.5476 0.3810

The observed user-day table compresses raw events into daily engagement states. This is the first major causal-design transformation: the analysis unit shifts from an individual watch event to a user observed on a calendar day.

Densify the User-Day Panel

A long-term outcome like next-day retention requires knowing when a user was inactive. The observed table contains days with at least one interaction, while a dense user-date grid records inactive days explicitly. The next cell creates a dense user-date grid across the sample window and fills missing activity with zeros.

This dense panel lets us define outcomes such as next_day_active and future_7day_active_days. Those outcomes are central to the long-term causal question because they move beyond immediate watch behavior.

# Densify the user-Day panel.
all_users = np.sort(user_day_observed["user_id"].unique())
all_dates = pd.date_range(
    user_day_observed["event_date"].min(),
    user_day_observed["event_date"].max(),
    freq="D",
)

dense_index = pd.MultiIndex.from_product(
    [all_users, all_dates],
    names=["user_id", "event_date"],
)

user_day = (
    user_day_observed.set_index(["user_id", "event_date"])
    .reindex(dense_index)
    .reset_index()
)

count_and_sum_columns = [
    "interactions",
    "unique_videos",
    "total_play_duration_ms",
    "high_watch_count",
    "complete_or_rewatch_count",
    "total_play_duration_sec",
]
rate_and_average_columns = [
    "avg_play_duration_ms",
    "avg_video_duration_ms",
    "avg_watch_ratio",
    "avg_play_duration_sec",
    "avg_video_duration_sec",
    "high_watch_share",
    "complete_or_rewatch_share",
]

user_day[count_and_sum_columns] = user_day[count_and_sum_columns].fillna(0)
user_day[rate_and_average_columns] = user_day[rate_and_average_columns].fillna(0)
user_day["active_day"] = (user_day["interactions"] > 0).astype(int)

user_day = user_day.sort_values(["user_id", "event_date"]).reset_index(drop=True)

print(f"Dense user-day panel shape: {user_day.shape}")
print(f"Users in panel: {user_day['user_id'].nunique():,}")
print(f"Dates per user: {user_day['event_date'].nunique():,}")
display(user_day.head(10))
Dense user-day panel shape: (5733, 16)
Users in panel: 91
Dates per user: 63
user_id event_date interactions unique_videos total_play_duration_ms avg_play_duration_ms avg_video_duration_ms avg_watch_ratio high_watch_count complete_or_rewatch_count total_play_duration_sec avg_play_duration_sec avg_video_duration_sec high_watch_share complete_or_rewatch_share active_day
0 14 2020-07-05 26.0000 26.0000 240,975.0000 9,268.2692 10,187.6538 1.0845 15.0000 12.0000 240.9750 9.2683 10.1877 0.5769 0.4615 1
1 14 2020-07-06 23.0000 23.0000 248,344.0000 10,797.5652 14,615.2174 1.0640 12.0000 10.0000 248.3440 10.7976 14.6152 0.5217 0.4348 1
2 14 2020-07-07 78.0000 78.0000 655,489.0000 8,403.7051 13,529.6410 0.8415 36.0000 27.0000 655.4890 8.4037 13.5296 0.4615 0.3462 1
3 14 2020-07-08 22.0000 22.0000 201,901.0000 9,177.3182 12,657.1818 0.9828 11.0000 9.0000 201.9010 9.1773 12.6572 0.5000 0.4091 1
4 14 2020-07-09 55.0000 55.0000 485,039.0000 8,818.8909 12,841.6727 0.8619 20.0000 15.0000 485.0390 8.8189 12.8417 0.3636 0.2727 1
5 14 2020-07-10 52.0000 52.0000 606,244.0000 11,658.5385 17,735.4038 1.1380 30.0000 21.0000 606.2440 11.6585 17.7354 0.5769 0.4038 1
6 14 2020-07-11 32.0000 32.0000 284,747.0000 8,898.3438 13,858.9688 0.9337 16.0000 10.0000 284.7470 8.8983 13.8590 0.5000 0.3125 1
7 14 2020-07-12 42.0000 42.0000 337,918.0000 8,045.6667 13,215.6429 0.7986 18.0000 11.0000 337.9180 8.0457 13.2156 0.4286 0.2619 1
8 14 2020-07-13 46.0000 46.0000 502,145.0000 10,916.1957 13,244.0000 1.0389 24.0000 19.0000 502.1450 10.9162 13.2440 0.5217 0.4130 1
9 14 2020-07-14 42.0000 42.0000 337,489.0000 8,035.4524 14,600.6190 0.9892 23.0000 16.0000 337.4890 8.0355 14.6006 0.5476 0.3810 1

The dense panel explicitly includes inactive dates, which is necessary for future retention-style outcomes. This prevents missing user-days from being treated as if they were outside the analysis.

Add Lagged State Variables

In sequential causal inference, the user’s past is part of the confounding structure. A user who watched heavily yesterday may be more likely to receive certain recommendations today and more likely to be active tomorrow. The next cell creates lagged and prior-window features that later notebooks can use as time-varying confounders.

These variables are adjustment and history features. They are the observed state before a daily treatment decision.

# Add lagged state variables.
lag_columns = [
    "active_day",
    "interactions",
    "total_play_duration_sec",
    "avg_watch_ratio",
    "high_watch_share",
]

for column in lag_columns:
    user_day[f"lag_1_{column}"] = user_day.groupby("user_id")[column].shift(1)
    user_day[f"prior_3day_{column}"] = user_day.groupby("user_id")[column].transform(
        lambda series: series.shift(1).rolling(window=3, min_periods=1).sum()
    )

lagged_columns = [column for column in user_day.columns if column.startswith("lag_1_") or column.startswith("prior_3day_")]
user_day[lagged_columns] = user_day[lagged_columns].fillna(0)

state_preview_columns = [
    "user_id",
    "event_date",
    "active_day",
    "interactions",
    "lag_1_active_day",
    "lag_1_interactions",
    "prior_3day_active_day",
    "prior_3day_interactions",
]

display(user_day[state_preview_columns].head(15))
user_id event_date active_day interactions lag_1_active_day lag_1_interactions prior_3day_active_day prior_3day_interactions
0 14 2020-07-05 1 26.0000 0.0000 0.0000 0.0000 0.0000
1 14 2020-07-06 1 23.0000 1.0000 26.0000 1.0000 26.0000
2 14 2020-07-07 1 78.0000 1.0000 23.0000 2.0000 49.0000
3 14 2020-07-08 1 22.0000 1.0000 78.0000 3.0000 127.0000
4 14 2020-07-09 1 55.0000 1.0000 22.0000 3.0000 123.0000
5 14 2020-07-10 1 52.0000 1.0000 55.0000 3.0000 155.0000
6 14 2020-07-11 1 32.0000 1.0000 52.0000 3.0000 129.0000
7 14 2020-07-12 1 42.0000 1.0000 32.0000 3.0000 139.0000
8 14 2020-07-13 1 46.0000 1.0000 42.0000 3.0000 126.0000
9 14 2020-07-14 1 42.0000 1.0000 46.0000 3.0000 120.0000
10 14 2020-07-15 1 10.0000 1.0000 42.0000 3.0000 130.0000
11 14 2020-07-16 1 87.0000 1.0000 10.0000 3.0000 98.0000
12 14 2020-07-17 1 93.0000 1.0000 87.0000 3.0000 139.0000
13 14 2020-07-18 1 117.0000 1.0000 93.0000 3.0000 190.0000
14 14 2020-07-19 1 42.0000 1.0000 117.0000 3.0000 297.0000

The lagged and prior-window columns capture the user’s pre-treatment state. These variables are central to later causal adjustment because prior engagement can influence both today’s recommendation exposure and tomorrow’s behavior.

Add Forward-Looking Outcomes

The next cell creates future outcome variables. These include next-day activity and multi-day future engagement. We keep these outcomes separate from treatment definitions so later notebooks can estimate how candidate treatment patterns affect future behavior.

Rows near the end of the sample window lack enough future days to define all outcomes. Those rows are marked as missing for the affected future horizons.

# Add forward-Looking outcomes.
future_base_columns = ["active_day", "interactions", "total_play_duration_sec"]
future_horizons = [1, 2, 3, 4, 5, 6, 7]

for column in future_base_columns:
    for horizon in future_horizons:
        user_day[f"lead_{horizon}_{column}"] = user_day.groupby("user_id")[column].shift(-horizon)

user_day["next_day_active"] = user_day["lead_1_active_day"]
user_day["next_day_interactions"] = user_day["lead_1_interactions"]
user_day["next_day_play_duration_sec"] = user_day["lead_1_total_play_duration_sec"]

user_day["future_3day_active_days"] = user_day[[f"lead_{horizon}_active_day" for horizon in [1, 2, 3]]].sum(axis=1, min_count=3)
user_day["future_3day_interactions"] = user_day[[f"lead_{horizon}_interactions" for horizon in [1, 2, 3]]].sum(axis=1, min_count=3)
user_day["future_3day_play_duration_sec"] = user_day[[f"lead_{horizon}_total_play_duration_sec" for horizon in [1, 2, 3]]].sum(axis=1, min_count=3)

user_day["future_7day_active_days"] = user_day[[f"lead_{horizon}_active_day" for horizon in future_horizons]].sum(axis=1, min_count=7)
user_day["future_7day_interactions"] = user_day[[f"lead_{horizon}_interactions" for horizon in future_horizons]].sum(axis=1, min_count=7)
user_day["future_7day_play_duration_sec"] = user_day[[f"lead_{horizon}_total_play_duration_sec" for horizon in future_horizons]].sum(axis=1, min_count=7)

outcome_preview_columns = [
    "user_id",
    "event_date",
    "active_day",
    "interactions",
    "next_day_active",
    "future_3day_active_days",
    "future_3day_interactions",
    "future_7day_active_days",
]

display(user_day[outcome_preview_columns].head(15))
user_id event_date active_day interactions next_day_active future_3day_active_days future_3day_interactions future_7day_active_days
0 14 2020-07-05 1 26.0000 1.0000 3.0000 123.0000 7.0000
1 14 2020-07-06 1 23.0000 1.0000 3.0000 155.0000 7.0000
2 14 2020-07-07 1 78.0000 1.0000 3.0000 129.0000 7.0000
3 14 2020-07-08 1 22.0000 1.0000 3.0000 139.0000 7.0000
4 14 2020-07-09 1 55.0000 1.0000 3.0000 126.0000 7.0000
5 14 2020-07-10 1 52.0000 1.0000 3.0000 120.0000 7.0000
6 14 2020-07-11 1 32.0000 1.0000 3.0000 130.0000 7.0000
7 14 2020-07-12 1 42.0000 1.0000 3.0000 98.0000 7.0000
8 14 2020-07-13 1 46.0000 1.0000 3.0000 139.0000 7.0000
9 14 2020-07-14 1 42.0000 1.0000 3.0000 190.0000 7.0000
10 14 2020-07-15 1 10.0000 1.0000 3.0000 297.0000 7.0000
11 14 2020-07-16 1 87.0000 1.0000 3.0000 252.0000 7.0000
12 14 2020-07-17 1 93.0000 1.0000 3.0000 264.0000 7.0000
13 14 2020-07-18 1 117.0000 1.0000 3.0000 227.0000 7.0000
14 14 2020-07-19 1 42.0000 1.0000 3.0000 202.0000 7.0000

The lead columns and future-window outcomes give the project its long-term target. Current-day behavior can now be related to next-day, 3-day, and 7-day future engagement while keeping future information out of the confounder set.

Candidate Treatment Definitions

For this project, a treatment is a daily user-day exposure pattern generated by a recommender system. At this stage, we define two transparent candidate treatments:

  • treatment_high_intensity: the user had an active day with interaction count in the upper quartile of active days.
  • treatment_high_watch_exposure: the user had an active day where the share of high-watch videos was above the median active-day share.

These are simple first definitions. They give us a concrete way to inspect treatment variation and confounding before more sophisticated modeling.

# Prepare candidate treatment definitions.
active_user_days = user_day["active_day"] == 1

high_intensity_threshold = user_day.loc[active_user_days, "interactions"].quantile(0.75)
high_watch_share_threshold = user_day.loc[active_user_days, "high_watch_share"].median()

user_day["treatment_high_intensity"] = (
    active_user_days & (user_day["interactions"] >= high_intensity_threshold)
).astype(int)
user_day["treatment_high_watch_exposure"] = (
    active_user_days & (user_day["high_watch_share"] >= high_watch_share_threshold)
).astype(int)

thresholds = pd.DataFrame(
    [
        {
            "candidate_treatment": "treatment_high_intensity",
            "rule": "active day and interactions >= active-day 75th percentile",
            "threshold": high_intensity_threshold,
        },
        {
            "candidate_treatment": "treatment_high_watch_exposure",
            "rule": "active day and high_watch_share >= active-day median",
            "threshold": high_watch_share_threshold,
        },
    ]
)

treatment_summary = user_day[
    ["treatment_high_intensity", "treatment_high_watch_exposure"]
].agg(["mean", "sum"]).T.rename(columns={"mean": "share_of_user_days", "sum": "treated_user_days"})

display(thresholds)
display(treatment_summary)
candidate_treatment rule threshold
0 treatment_high_intensity active day and interactions >= active-day 75th... 70.0000
1 treatment_high_watch_exposure active day and high_watch_share >= active-day ... 0.4773
share_of_user_days treated_user_days
treatment_high_intensity 0.2501 1,434.0000
treatment_high_watch_exposure 0.4842 2,776.0000

The treatment summary shows whether the first two exposure definitions have usable variation. These simple definitions let us test whether the data can support a treated-versus-control comparison before committing to a final estimand.

Plot Daily Treatment and Outcome Rates

This plot checks whether the candidate treatments and next-day activity vary over time. A useful causal study needs variation. If a treatment is always on, always off, or perfectly aligned with a single date, it is hard to estimate a meaningful effect.

# Plot daily treatment and outcome rates.
daily_panel_summary = (
    user_day.groupby("event_date")
    .agg(
        active_rate=("active_day", "mean"),
        high_intensity_rate=("treatment_high_intensity", "mean"),
        high_watch_exposure_rate=("treatment_high_watch_exposure", "mean"),
        next_day_active_rate=("next_day_active", "mean"),
    )
    .reset_index()
)

plot_daily = daily_panel_summary.melt(
    id_vars="event_date",
    value_vars=["active_rate", "high_intensity_rate", "high_watch_exposure_rate", "next_day_active_rate"],
    var_name="metric",
    value_name="rate",
)

fig, ax = plt.subplots(figsize=(13, 5))
sns.lineplot(data=plot_daily, x="event_date", y="rate", hue="metric", marker="o", linewidth=1.5, ax=ax)
ax.set_title("Daily Treatment and Outcome Rates in the Dense User-Day Panel")
ax.set_xlabel("Date")
ax.set_ylabel("Rate")
ax.yaxis.set_major_formatter(lambda value, _: f"{value:.0%}")
ax.tick_params(axis="x", rotation=35)
plt.tight_layout()
plt.show()

The time-series plot checks whether treatment and outcome rates vary over the calendar window. If treatment prevalence changed sharply by date, later models would need calendar controls; this plot starts that diagnostic conversation.

Naive Associations Need Adjustment

The next table compares future outcomes between treated and untreated active user-days. This is useful as a descriptive diagnostic before causal estimation. Treated days are likely different before treatment happens. For example, users with high activity today may already be more engaged, and that prior engagement may predict future activity regardless of today’s exposure.

The purpose of this table is to create intuition and motivate the confounding checks that follow.

# Naive associations are not causal effects.
def summarize_naive_association(data, treatment_col, outcome_cols):
    """
    Summarize naive treatment-outcome differences.
    
    Idea
    ----
    The helper gives a descriptive baseline before adjustment, making clear how much of the apparent long-term effect may be confounded by user history.
    
    Parameters
    ----------
    data : object
        Input analysis table for the current project step.
    treatment_col : object
        Column indicating treatment or exposure status.
    outcome_cols : object
        Outcome columns to summarize or model.
    
    Returns
    -------
    pandas.DataFrame
        Outcome means and naive differences by treatment group.
    """
    rows = []
    analytic = data.loc[data["active_day"].eq(1)].copy()
    for outcome_col in outcome_cols:
        subset = analytic.dropna(subset=[treatment_col, outcome_col])
        grouped = subset.groupby(treatment_col)[outcome_col].agg(["mean", "count", "std"])
        if set(grouped.index) >= {0, 1}:
            control_mean = grouped.loc[0, "mean"]
            treated_mean = grouped.loc[1, "mean"]
            rows.append(
                {
                    "treatment": treatment_col,
                    "outcome": outcome_col,
                    "control_mean": control_mean,
                    "treated_mean": treated_mean,
                    "difference": treated_mean - control_mean,
                    "relative_lift": (treated_mean / control_mean - 1) if control_mean != 0 else np.nan,
                    "control_days": grouped.loc[0, "count"],
                    "treated_days": grouped.loc[1, "count"],
                }
            )
    return pd.DataFrame(rows)

outcome_columns = [
    "next_day_active",
    "future_3day_active_days",
    "future_3day_interactions",
    "future_7day_active_days",
]

naive_associations = pd.concat(
    [
        summarize_naive_association(user_day, "treatment_high_intensity", outcome_columns),
        summarize_naive_association(user_day, "treatment_high_watch_exposure", outcome_columns),
    ],
    ignore_index=True,
)

display(naive_associations)
treatment outcome control_mean treated_mean difference relative_lift control_days treated_days
0 treatment_high_intensity next_day_active 0.9750 0.9868 0.0117 0.0120 4048 1434
1 treatment_high_intensity future_3day_active_days 2.9371 2.9477 0.0106 0.0036 3880 1434
2 treatment_high_intensity future_3day_interactions 138.8616 207.0342 68.1726 0.4909 3880 1434
3 treatment_high_intensity future_7day_active_days 6.8580 6.8562 -0.0017 -0.0003 3528 1433
4 treatment_high_watch_exposure next_day_active 0.9835 0.9728 -0.0107 -0.0109 2728 2754
5 treatment_high_watch_exposure future_3day_active_days 2.9479 2.9321 -0.0158 -0.0054 2649 2665
6 treatment_high_watch_exposure future_3day_interactions 157.7493 156.7700 -0.9794 -0.0062 2649 2665
7 treatment_high_watch_exposure future_7day_active_days 6.8719 6.8430 -0.0289 -0.0042 2483 2478

The naive comparisons describe how future outcomes differ between treated and untreated days. They should be read descriptively. Their value is motivational. Visible differences raise the question of whether those differences remain after accounting for prior user state.

Check Time-Varying Confounding

A major reason this project needs causal methods is that prior user state can affect both today’s treatment and future outcomes. The next cell computes standardized mean differences for pre-treatment covariates between treated and untreated active days. Large imbalances mean that a naive treated-versus-control comparison is likely confounded.

A standardized mean difference is the treated-control difference in means divided by a pooled standard deviation. Values far from zero indicate imbalance in the pre-treatment state.

# Check time-Varying confounding.
def standardized_mean_differences(data, treatment_col, covariate_cols):
    """
    Compute standardized mean differences for several covariates.
    
    Idea
    ----
    This summarizes observed treated-control imbalance across the covariates used for adjustment in the sequential causal panel.
    
    Parameters
    ----------
    data : object
        Input analysis table for the current project step.
    treatment_col : object
        Column indicating treatment or exposure status.
    covariate_cols : object
        Covariate columns to summarize for balance.
    
    Returns
    -------
    pandas.DataFrame
        One row per covariate with treated means, control means, and standardized differences.
    """
    analytic = data.loc[data["active_day"].eq(1)].dropna(subset=[treatment_col]).copy()
    rows = []
    for covariate in covariate_cols:
        treated = analytic.loc[analytic[treatment_col].eq(1), covariate].dropna()
        control = analytic.loc[analytic[treatment_col].eq(0), covariate].dropna()
        pooled_sd = np.sqrt((treated.var(ddof=1) + control.var(ddof=1)) / 2)
        rows.append(
            {
                "treatment": treatment_col,
                "covariate": covariate,
                "treated_mean": treated.mean(),
                "control_mean": control.mean(),
                "smd": (treated.mean() - control.mean()) / pooled_sd if pooled_sd and not np.isnan(pooled_sd) else np.nan,
            }
        )
    return pd.DataFrame(rows)

pre_treatment_covariates = [
    "lag_1_active_day",
    "lag_1_interactions",
    "lag_1_total_play_duration_sec",
    "lag_1_avg_watch_ratio",
    "prior_3day_active_day",
    "prior_3day_interactions",
    "prior_3day_total_play_duration_sec",
    "prior_3day_high_watch_share",
]

confounding_balance = pd.concat(
    [
        standardized_mean_differences(user_day, "treatment_high_intensity", pre_treatment_covariates),
        standardized_mean_differences(user_day, "treatment_high_watch_exposure", pre_treatment_covariates),
    ],
    ignore_index=True,
)

display(confounding_balance.sort_values(["treatment", "smd"], key=lambda col: col.abs() if col.name == "smd" else col))
treatment covariate treated_mean control_mean smd
3 treatment_high_intensity lag_1_avg_watch_ratio 0.9103 0.9208 -0.0193
0 treatment_high_intensity lag_1_active_day 0.9770 0.9654 0.0694
7 treatment_high_intensity prior_3day_high_watch_share 1.4207 1.3741 0.1029
4 treatment_high_intensity prior_3day_active_day 2.9303 2.8279 0.2198
2 treatment_high_intensity lag_1_total_play_duration_sec 617.3946 388.2157 0.7200
6 treatment_high_intensity prior_3day_total_play_duration_sec 1,794.1988 1,177.3717 0.8370
1 treatment_high_intensity lag_1_interactions 71.7364 44.2764 0.8406
5 treatment_high_intensity prior_3day_interactions 207.7497 134.4448 1.0026
8 treatment_high_watch_exposure lag_1_active_day 0.9672 0.9696 -0.0135
12 treatment_high_watch_exposure prior_3day_active_day 2.8483 2.8606 -0.0232
9 treatment_high_watch_exposure lag_1_interactions 50.8336 51.9457 -0.0332
13 treatment_high_watch_exposure prior_3day_interactions 151.6120 155.2572 -0.0452
10 treatment_high_watch_exposure lag_1_total_play_duration_sec 479.3493 415.6172 0.2007
14 treatment_high_watch_exposure prior_3day_total_play_duration_sec 1,424.6386 1,249.1272 0.2284
11 treatment_high_watch_exposure lag_1_avg_watch_ratio 1.0097 0.8259 0.3077
15 treatment_high_watch_exposure prior_3day_high_watch_share 1.5894 1.1817 0.9546

The standardized mean differences show whether treated and untreated days already looked different before treatment. Any meaningful imbalance here supports the central claim of the project. Sequential recommender logs need causal adjustment alongside outcome comparison.

Visualize Covariate Imbalance

The heatmap below gives a quick visual read of confounding. Darker positive or negative values mean treated and untreated active days had different histories before the treatment day. This is exactly the kind of structure that motivates marginal structural models and g-computation in later notebooks.

balance_heatmap = confounding_balance.pivot(index="covariate", columns="treatment", values="smd")

fig, ax = plt.subplots(figsize=(9, 5.5))
sns.heatmap(
    balance_heatmap,
    annot=True,
    fmt=".2f",
    cmap="vlag",
    center=0,
    linewidths=0.5,
    ax=ax,
)
ax.set_title("Standardized Mean Differences in Pre-Treatment State")
ax.set_xlabel("Candidate treatment")
ax.set_ylabel("Pre-treatment covariate")
plt.tight_layout()
plt.show()

The heatmap turns the balance table into a quick diagnostic. Larger color contrasts indicate pre-treatment differences that later notebooks must address with propensity models, marginal structural models, or g-computation.

Join Metadata for Diagnostic Checks

User metadata can help explain why treatment assignment differs across users. The next cell joins the interaction sample to user activity segment and item category features. This is a sanity check that the metadata keys connect cleanly to the interaction log before the final feature engineering step.

# Join metadata for diagnostic checks.
user_metadata_columns = [
    "user_id",
    "user_active_degree",
    "is_lowactive_period",
    "is_live_streamer",
    "is_video_author",
    "follow_user_num",
    "fans_user_num",
    "friend_user_num",
    "register_days",
]

interaction_metadata_sample = (
    interactions.merge(user_features[user_metadata_columns], on="user_id", how="left")
    .merge(item_categories[["video_id", "category_count", "first_category"]], on="video_id", how="left")
)

metadata_diagnostics = pd.DataFrame(
    [
        {"metric": "rows", "value": len(interaction_metadata_sample)},
        {"metric": "missing_user_active_degree_rate", "value": interaction_metadata_sample["user_active_degree"].isna().mean()},
        {"metric": "missing_item_category_rate", "value": interaction_metadata_sample["category_count"].isna().mean()},
        {"metric": "unique_user_activity_segments", "value": interaction_metadata_sample["user_active_degree"].nunique(dropna=True)},
        {"metric": "unique_first_categories", "value": interaction_metadata_sample["first_category"].nunique(dropna=True)},
    ]
)

display(metadata_diagnostics)

display(
    interaction_metadata_sample.groupby("user_active_degree")
    .agg(
        rows=("video_id", "size"),
        users=("user_id", "nunique"),
        avg_watch_ratio=("watch_ratio", "mean"),
        avg_play_duration_sec=("play_duration_sec", "mean"),
    )
    .sort_values("rows", ascending=False)
)
metric value
0 rows 300,000.0000
1 missing_user_active_degree_rate 0.0000
2 missing_item_category_rate 0.0000
3 unique_user_activity_segments 3.0000
4 unique_first_categories 30.0000
rows users avg_watch_ratio avg_play_duration_sec
user_active_degree
full_active 230406 70 0.9199 8.7178
high_active 62965 19 0.9250 8.7592
UNKNOWN 6629 2 0.8329 7.9533

The join diagnostics confirm that user metadata and item categories connect to the sampled interaction log. This means later models can enrich the history-only panel with baseline user context or item context if the causal design needs more complete adjustment.

Inspect Item Daily Popularity Features

Item daily features can be useful later because recommendation exposure is often influenced by item popularity. A popular item may be more likely to be shown, and popular items may also generate different future engagement. The next cell summarizes the sampled item daily table and checks the distribution of simple popularity rates.

# Inspect item daily popularity features.
item_daily_summary = item_daily[
    [
        "show_cnt",
        "show_user_num",
        "play_cnt",
        "play_user_num",
        "complete_play_cnt",
        "like_cnt",
        "comment_cnt",
        "share_cnt",
        "collect_cnt",
        "item_play_rate",
        "item_like_rate_per_play",
    ]
].describe(percentiles=[0.05, 0.25, 0.5, 0.75, 0.95]).T

display(item_daily_summary)

fig, axes = plt.subplots(1, 2, figsize=(14, 4.5))
sns.histplot(data=item_daily, x="show_cnt", bins=60, ax=axes[0], color="#2A6F97")
axes[0].set_title("Daily Item Show Counts")
axes[0].set_xlabel("Show count")
axes[0].set_yscale("log")

sns.histplot(data=item_daily, x="item_play_rate", bins=60, ax=axes[1], color="#5C946E")
axes[1].set_title("Daily Item Play Rate")
axes[1].set_xlabel("Play count / show count")

plt.tight_layout()
plt.show()
count mean std min 5% 25% 50% 75% 95% max
show_cnt 150,000.0000 99,455.6275 565,375.6798 0.0000 3.0000 74.0000 1,762.0000 28,220.0000 415,838.5500 36,053,957.0000
show_user_num 150,000.0000 93,436.8595 542,642.7612 0.0000 3.0000 63.0000 1,474.0000 25,346.0000 387,015.8000 34,875,019.0000
play_cnt 150,000.0000 99,323.0334 584,500.5090 0.0000 0.0000 19.0000 892.0000 24,438.0000 413,801.5000 37,966,319.0000
play_user_num 150,000.0000 89,345.7759 534,001.9580 0.0000 0.0000 16.0000 736.0000 21,404.2500 369,398.3500 34,501,624.0000
complete_play_cnt 150,000.0000 54,362.3796 354,878.3167 0.0000 0.0000 5.0000 306.0000 10,966.2500 216,402.5500 23,892,867.0000
like_cnt 150,000.0000 2,972.5705 18,322.5665 0.0000 0.0000 0.0000 13.0000 521.2500 12,800.1000 1,286,185.0000
comment_cnt 150,000.0000 130.1410 1,613.5446 0.0000 0.0000 0.0000 0.0000 11.0000 363.0000 182,959.0000
share_cnt 150,000.0000 72.2995 1,050.6124 0.0000 0.0000 0.0000 0.0000 4.0000 157.0000 172,181.0000
collect_cnt 116,617.0000 14.5537 187.7763 0.0000 0.0000 0.0000 0.0000 1.0000 39.0000 41,399.0000
item_play_rate 149,659.0000 0.6062 0.4483 0.0000 0.0000 0.2485 0.6210 0.9876 1.0405 38.0000
item_like_rate_per_play 141,044.0000 0.0278 0.0529 0.0000 0.0000 0.0000 0.0138 0.0329 0.0997 2.0000

The item-daily summaries show that item popularity is highly uneven. That unevenness matters because popular videos may be more likely to be recommended and may also produce different engagement patterns, making popularity a potential confounder or stratification variable.

Causal Readiness Checklist

This checklist summarizes whether the current sample is adequate for the next stage of the project. The checks focus on the practical requirements for causal modeling. Do we have repeated users, a usable time span, treatment variation, future outcome availability, and pre-treatment state variables? Passing these checks supports moving to formal modeling.

# Prepare causal readiness checklist.
active_days_per_user = user_day.groupby("user_id")["active_day"].sum()
future_3day_available = user_day["future_3day_active_days"].notna().mean()
future_7day_available = user_day["future_7day_active_days"].notna().mean()

treatment_rates = user_day[["treatment_high_intensity", "treatment_high_watch_exposure"]].mean()

readiness_checks = pd.DataFrame(
    [
        {
            "check": "at least 50 users in sample",
            "value": user_day["user_id"].nunique(),
            "passes": user_day["user_id"].nunique() >= 50,
        },
        {
            "check": "median user has at least 7 active days",
            "value": active_days_per_user.median(),
            "passes": active_days_per_user.median() >= 7,
        },
        {
            "check": "at least 30 calendar dates",
            "value": user_day["event_date"].nunique(),
            "passes": user_day["event_date"].nunique() >= 30,
        },
        {
            "check": "high-intensity treatment has non-trivial variation",
            "value": treatment_rates["treatment_high_intensity"],
            "passes": 0.01 < treatment_rates["treatment_high_intensity"] < 0.99,
        },
        {
            "check": "high-watch treatment has non-trivial variation",
            "value": treatment_rates["treatment_high_watch_exposure"],
            "passes": 0.01 < treatment_rates["treatment_high_watch_exposure"] < 0.99,
        },
        {
            "check": "future 3-day outcomes available for most rows",
            "value": future_3day_available,
            "passes": future_3day_available >= 0.80,
        },
        {
            "check": "future 7-day outcomes available for most rows",
            "value": future_7day_available,
            "passes": future_7day_available >= 0.70,
        },
        {
            "check": "lagged confounders have no missing values after filling panel starts",
            "value": user_day[pre_treatment_covariates].isna().mean().max(),
            "passes": user_day[pre_treatment_covariates].isna().mean().max() == 0,
        },
    ]
)

display(readiness_checks)
check value passes
0 at least 50 users in sample 91.0000 True
1 median user has at least 7 active days 62.0000 True
2 at least 30 calendar dates 63.0000 True
3 high-intensity treatment has non-trivial varia... 0.2501 True
4 high-watch treatment has non-trivial variation 0.4842 True
5 future 3-day outcomes available for most rows 0.9524 True
6 future 7-day outcomes available for most rows 0.8889 True
7 lagged confounders have no missing values afte... 0.0000 True

The readiness checklist condenses the EDA into practical criteria for causal modeling. Passing these checks shows that the sample has enough users, time, treatment variation, and future outcomes to justify moving to formal estimand definition.

The saved files are the handoff from raw EDA to causal design. Future notebooks can load the cleaned interaction sample and dense user-day panel directly, which keeps the project modular and avoids repeating expensive archive parsing.

Takeaways and Next Step

This notebook turns KuaiRec from a raw interaction log into a sequential causal analysis dataset. The key object is the dense user-day panel. It contains the current day’s activity, prior user state, candidate treatment definitions, and future outcomes.

The descriptive comparisons in this notebook motivate adjusted estimators before causal interpretation. They show why causal methods are needed. The confounding checks make the main issue visible: treatment days and non-treatment days differ in their pre-treatment histories. The next notebook should formalize the long-term outcome definitions and decide which treatment-outcome pair will be the primary estimand for the project.