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_json1415log = get_logger("models.h5")16PROC = ROOT / "data" / "processed"17B = 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}, ROOT / "experiments" / "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