SPB Git

spb/wp37_uqo

Public
4.7 KB · 64 lines python
1"""Model C — competitive spillovers (H4), descriptive lead–lag only. Preregistration §2 H4.23Within matched groups (A+B, storefront), regress a banner's weekly log-price change on rivals' current and lagged4changes (k = 0..3), with group-week and banner fixed effects; leads (k = -1, -2) as placebo. Rival change = mean change of5the other banners in the group that week. Same-company vs cross-company rivals estimated separately. No causal claim:6contemporaneous terms absorb common shocks; lagged terms are lead–lag associations.7"""8import json, numpy as np, pandas as pd, pyfixest as pf9from src.common import path, ROOT, get_logger, ledger, seed_everything10log = get_logger("modelC"); P = path("processed"); E = ROOT / "experiments"1112def main():13    seed_everything(); out = {}14    pm = pd.read_parquet(P / "panel_matched_AB.parquet"); pm = pm[(pm.channel == "online_storefront") & (~pm.ambiguous)].copy()15    pm = pm.drop_duplicates(["group_id", "week", "banner"]).sort_values(["group_id", "banner", "week"])16    pm["dlp"] = pm.groupby(["group_id", "banner"]).log_p.diff()17    pm["gap_w"] = (pm.week - pm.groupby(["group_id", "banner"]).week.shift(1)).dt.days18    pm.loc[pm.gap_w != 7, "dlp"] = np.nan19    # rival mean change, same-company vs cross-company20    tot = pm.groupby(["group_id", "week"]).dlp.transform(lambda s: s.sum(min_count=1)); cnt = pm.groupby(["group_id", "week"]).dlp.transform("count")21    pm["rival_all"] = (tot - pm.dlp.fillna(0)) / (cnt - pm.dlp.notna().astype(int)).replace(0, np.nan)22    def rival_by(same):23        rows = []24        for (g, w), d in pm.groupby(["group_id", "week"]):25            for i, r in d.iterrows():26                oth = d[(d.index != i) & ((d.company == r.company) == same)].dlp.dropna()27                rows.append((i, oth.mean() if len(oth) else np.nan))28        return pd.Series(dict(rows))29    pm["rival_same"] = rival_by(True); pm["rival_cross"] = rival_by(False)30    for col in ["rival_all", "rival_same", "rival_cross"]:31        for k in (1, 2, 3):32            pm[f"{col}_l{k}"] = pm.groupby(["group_id", "banner"])[col].shift(k)33        for k in (1, 2):34            pm[f"{col}_f{k}"] = pm.groupby(["group_id", "banner"])[col].shift(-k)35    pm["gw"] = pm.group_id + "_" + pm.week.dt.strftime("%Y%m%d")36    res = []37    for col in ["rival_all", "rival_same", "rival_cross"]:38        terms = [col] + [f"{col}_l{k}" for k in (1, 2, 3)] + [f"{col}_f{k}" for k in (1, 2)]39        d = pm.dropna(subset=["dlp"] + terms)40        if len(d) < 500: log.warning("%s: only %d obs", col, len(d)); continue41        fit = pf.feols("dlp ~ " + " + ".join(terms) + " | banner + week", data=d, vcov={"CRV1": "group_id"})42        for t in terms:43            res.append(dict(rival=col, term=t, coef=float(fit.coef()[t]), se=float(fit.se()[t]), n=int(fit._N)))44        out[col] = dict(n=int(fit._N), coefs={t: float(fit.coef()[t]) for t in terms}, ses={t: float(fit.se()[t]) for t in terms})45        # descriptive: probability a banner changes price in w given rival changed in w-146        d["rival_changed_l1"] = d[f"{col}_l1"].abs() > 1e-9; d["own_changed"] = d.dlp.abs() > 1e-947        ct = d.groupby("rival_changed_l1").own_changed.mean()48        out[col]["P_own_change_given_rival_change_l1"] = float(ct.get(True, np.nan)); out[col]["P_own_change_given_no_rival_change_l1"] = float(ct.get(False, np.nan))49    pd.DataFrame(res).to_csv(P / "modelC_distributed_lags.csv", index=False)50    # Promotion co-movement: share of group-weeks in which >=2 banners are simultaneously at a low (p <= 0.9 x own 8-week mode)51    pm["ref"] = pm.groupby(["group_id", "banner"]).p_mode.transform(lambda s: s.rolling(8, min_periods=1).apply(lambda x: pd.Series(x).mode().max()))52    pm["low"] = pm.p_mode <= 0.9 * pm.ref53    gl = pm.groupby(["group_id", "week"]).agg(n=("banner", "size"), nlow=("low", "sum"))54    gl = gl[gl.n >= 2]; out["promo_comovement"] = dict(group_weeks=int(len(gl)), share_any_low=float((gl.nlow >= 1).mean()), share_two_plus_low=float((gl.nlow >= 2).mean()),55                                                      expected_two_plus_if_independent=float(((gl.nlow / gl.n).mean()) ** 2))56    (E / "modelC_summary.json").write_text(json.dumps(out, indent=2, default=float))57    for col in out:58        if col.startswith("rival"): log.info("%s: n=%d, k0 %.3f (%.3f), l1 %.3f (%.3f), l2 %.3f (%.3f), f1 %.3f (%.3f)", col, out[col]["n"], out[col]["coefs"][col], out[col]["ses"][col], out[col]["coefs"][col + "_l1"], out[col]["ses"][col + "_l1"], out[col]["coefs"][col + "_l2"], out[col]["ses"][col + "_l2"], out[col]["coefs"][col + "_f1"], out[col]["ses"][col + "_f1"])59    log.info("promo co-movement: %s", out["promo_comovement"])60    ledger("4", "model_c", "distributed lags estimated (descriptive)")6162if __name__ == "__main__":63    main()64
Files · main
Menu

Theme