SPB Git

spb/wp38_uqo

Public
8.6 KB · 129 lines python
1"""H5 — Cross-merchant price dispersion of matched products (cross-section at the snapshot).23Sample S_M: match groups (tiers A+B primary; A+B+C sensitivity) with comparable variant structure, both stores in S_R4(region known), stores not siblings, groups spanning >= 2 stores after filters.5Model: log P_im = alpha_i + gamma_r(m) + delta_platform(m) + b1 log n_m + b2 Reseller_m + b3 Certified_m + e_im,6group fixed effects absorbed by within-group demeaning; SE clustered by store; joint test of region effects and the7t-test on Reseller use a wild cluster bootstrap (Rademacher, 9 999 draws, restricted residuals).8Outputs experiments/h5_hedonic.json, data/processed/h5_groups.parquet, data/processed/h5_region_effects.csv9"""10from __future__ import annotations11import json12import numpy as np, pandas as pd13from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR1415log = get_logger("models.h5")16PROC = PROC_DIR17B = 9999181920def build_sample(tiers):21    pr = pd.read_parquet(P["interim"] / "match_pairs.parquet")22    li = pd.read_parquet(P["interim"] / "listings_clean.parquet").set_index("uid")23    sp = pd.read_parquet(PROC / "store_panel.parquet").set_index("id")24    sib = pd.read_parquet(P["interim"] / "sibling_stores.parquet").set_index("id").sibling_rep25    pr = pr[pr.tier.isin(tiers) & pr.comparable]26    ok = lambda s: s.map(sp.region_final).fillna("Inconnue") != "Inconnue"27    pr = pr[ok(pr.store_a) & ok(pr.store_b)]28    pr = pr[pr.store_a.map(sib).fillna(pr.store_a) != pr.store_b.map(sib).fillna(pr.store_b)]29    import networkx as nx30    G = nx.Graph(); G.add_edges_from(pr[["uid_a", "uid_b"]].itertuples(index=False, name=None))31    rows = [(g, u) for g, comp in enumerate(nx.connected_components(G)) for u in comp]32    d = pd.DataFrame(rows, columns=["group", "uid"])33    d["store"] = d.uid.map(li.store_id); d["price"] = d.uid.map(li.price); d["category"] = d.uid.map(li.category)34    d["region"] = d.store.map(sp.region_final); d["platform"] = d.store.map(sp.platform).fillna("other")35    d["log_n"] = np.log(d.store.map(sp.n_listings)); d["reseller"] = d.store.map(sp.store_kind).isin(["revendeur", "collectif"]).astype(float)36    d["certified"] = d.store.map(sp.certified).fillna(False).astype(float); d["lp"] = np.log(d.price)37    gs = d.groupby("group").store.nunique(); d = d[d.group.map(gs) >= 2]38    return d394041def within(df, cols):42    return df[cols] - df.groupby("group")[cols].transform("mean")434445def fit(d, rng, label):46    # design: region dummies (drop the largest region), platform dummies (drop shopify), controls47    regions = sorted(d.region.unique()); base_r = d.region.value_counts().idxmax()48    plats = sorted(d.platform.unique()); base_p = "shopify" if "shopify" in plats else plats[0]49    X = pd.DataFrame(index=d.index)50    for r in regions:51        if r != base_r: X[f"r::{r}"] = (d.region == r).astype(float)52    for p_ in plats:53        if p_ != base_p: X[f"p::{p_}"] = (d.platform == p_).astype(float)54    X["log_n"] = d.log_n; X["reseller"] = d.reseller; X["certified"] = d.certified55    cols = list(X.columns); dd = pd.concat([d[["group", "store", "lp"]], X], axis=1)56    W = within(dd, cols + ["lp"]); Xw = W[cols].values; yw = W["lp"].values57    keep = Xw.std(0) > 1e-10; Xw = Xw[:, keep]; cols = [c for c, k in zip(cols, keep) if k]58    beta, *_ = np.linalg.lstsq(Xw, yw, rcond=None); e = yw - Xw @ beta59    cl = d.store.astype("category").cat.codes.values; G = cl.max() + 160    XtX_inv = np.linalg.pinv(Xw.T @ Xw)61    def cluster_cov(Xw, e):62        meat = np.zeros((Xw.shape[1], Xw.shape[1]))63        for g in range(G):64            m = cl == g; s = Xw[m].T @ e[m]; meat += np.outer(s, s)65        return XtX_inv @ meat @ XtX_inv * G / (G - 1)66    V = cluster_cov(Xw, e); se = np.sqrt(np.diag(V))67    ridx = [i for i, c in enumerate(cols) if c.startswith("r::")]68    def wald(beta, V, idx):69        b = beta[idx]; Vi = V[np.ix_(idx, idx)]; return float(b @ np.linalg.pinv(Vi) @ b)70    W_obs = wald(beta, V, ridx)71    # wild cluster bootstrap: restricted model (no region dummies) residuals, Rademacher by store72    nr = [i for i in range(len(cols)) if i not in ridx]73    b_r, *_ = np.linalg.lstsq(Xw[:, nr], yw, rcond=None); e_r = yw - Xw[:, nr] @ b_r; yhat_r = Xw[:, nr] @ b_r74    Wb = np.empty(B)75    i_res = cols.index("reseller") if "reseller" in cols else None76    nr2 = [i for i in range(len(cols)) if i != i_res] if i_res is not None else None77    if i_res is not None:78        b_r2, *_ = np.linalg.lstsq(Xw[:, nr2], yw, rcond=None); e_r2 = yw - Xw[:, nr2] @ b_r2; yhat_r2 = Xw[:, nr2] @ b_r279    tb = np.empty(B); t_obs = beta[i_res] / se[i_res] if i_res is not None else np.nan80    for b in range(B):81        w = rng.choice([-1.0, 1.0], G)[cl]82        yb = yhat_r + e_r * w; bb = XtX_inv @ (Xw.T @ yb); eb = yb - Xw @ bb83        Wb[b] = wald(bb, cluster_cov(Xw, eb), ridx)84        if i_res is not None:85            yb2 = yhat_r2 + e_r2 * w; bb2 = XtX_inv @ (Xw.T @ yb2); eb2 = yb2 - Xw @ bb2; V2 = cluster_cov(Xw, eb2)86            tb[b] = bb2[i_res] / np.sqrt(V2[i_res, i_res])87    p_region = float((np.sum(Wb >= W_obs) + 1) / (B + 1))88    p_res = float((np.sum(np.abs(tb) >= abs(t_obs)) + 1) / (B + 1)) if i_res is not None else None89    n_groups = int(d.group.nunique())90    gaps = d.groupby("group").lp.agg(lambda s: s.max() - s.min())91    reg_stores = d.groupby("region").store.nunique()92    res = {"label": label, "obs": int(len(d)), "groups": n_groups, "stores": int(G), "regions": len(regions), "base_region": base_r,93           "regions_with_ge5_stores": int((reg_stores >= 5).sum()), "stores_by_region": reg_stores.to_dict(),94           "coef": {c: {"b": round(float(b), 4), "se_cluster": round(float(s), 4)} for c, b, s in zip(cols, beta, se)},95           "wald_region": round(W_obs, 3), "df_region": len(ridx), "p_region_wildboot": p_region, "t_reseller": round(float(t_obs), 3) if i_res is not None else None, "p_reseller_wildboot": p_res,96           "within_R2": round(float(1 - e.var() / yw.var()), 4),97           "gap_median": round(float(gaps.median()), 4), "gap_p90": round(float(gaps.quantile(0.9)), 4), "share_gap_gt5pct": round(float((gaps > 0.05).mean()), 4),98           "share_gap_zero": round(float((gaps < 1e-9).mean()), 4), "B": B}99    eff = pd.DataFrame({"region": [c[3:] for c in cols if c.startswith("r::")], "effect": [beta[i] for i in ridx], "se": [se[i] for i in ridx]})100    eff = pd.concat([eff, pd.DataFrame({"region": [base_r], "effect": [0.0], "se": [0.0]})]); eff["label"] = label; eff["n_stores"] = eff.region.map(reg_stores)101    return res, eff, d102103104def main():105    rng = np.random.default_rng(SEED); out = []; effs = []106    for label, tiers in (("primary_AB", ["A", "B"]), ("sens_ABC", ["A", "B", "C"])):107        d = build_sample(tiers)108        if d.group.nunique() < 20:109            out.append({"label": label, "status": "insufficient", "groups": int(d.group.nunique())}); continue110        res, eff, dd = fit(d, rng, label); out.append(res); effs.append(eff); log.info(json.dumps({k: v for k, v in res.items() if k not in ("coef", "stores_by_region")}))111        if label == "primary_AB":112            dd.to_parquet(PROC / "h5_groups.parquet", index=False); register_output(PROC / "h5_groups.parquet", "h5")113    pd.concat(effs).to_csv(PROC / "h5_region_effects.csv", index=False)114    # descriptive certification contrast (NOT H6): within-category, store-clustered115    li = pd.read_parquet(P["interim"] / "listings_clean.parquet"); sp = pd.read_parquet(PROC / "store_panel.parquet").set_index("id")116    s = li[li.f_analysis_price].copy(); s["cert"] = s.store_id.map(sp.cert_adq | sp.cert_lpq).fillna(False).astype(float)117    s["region"] = s.store_id.map(sp.region_final); s = s[s.region.notna() & (s.region != "Inconnue")]118    s["y"] = s.log_price - s.groupby("category").log_price.transform("mean"); s["x"] = s.cert - s.groupby("category").cert.transform("mean")119    b = float((s.x * s.y).sum() / (s.x ** 2).sum()); r = s.y - b * s.x120    g = s.assign(r=r).groupby("store_id").apply(lambda h: (h.x * h.r).sum()); se = float(np.sqrt((g ** 2).sum()) / (s.x ** 2).sum())121    cert = {"note": "descriptive store-certification contrast (Aliments du Québec / Les Produits du Québec), category FE, store-clustered SE — NOT a test of H6",122            "obs": int(len(s)), "stores": int(s.store_id.nunique()), "certified_stores": int(s.groupby('store_id').cert.first().sum()), "b_log_price": round(b, 4), "se": round(se, 4)}123    write_json({"models": out, "certification_contrast": cert}, EXP_DIR / "h5_hedonic.json", "h5"); ledger("models.h5_hedonic", models=[{k: v for k, v in m.items() if k not in ("coef", "stores_by_region")} for m in out], cert=cert)124    log.info(json.dumps(cert))125126127if __name__ == "__main__":128    main()129
Files · 868e380d70bce866dfb2e5b5ff56d54a8909d47d
Menu

Theme