phase4: models A (dispersion), C (spillovers), D (indices), E (basket MIP), F (forecasts); pulp pinned <3
17 changed files +930 −2
added
experiments/modelA_summary.json
+59 −0
@@ -0,0 +1,59 @@ | |||||
| 1 | +{ | ||||
| 2 | + "gap_all": { | ||||
| 3 | + "n": 30042, | ||||
| 4 | + "median": 0.15415067982725827, | ||||
| 5 | + "p25": 0.05901417143705534, | ||||
| 6 | + "p75": 0.27866231037551104, | ||||
| 7 | + "mean": 0.18936608290604118, | ||||
| 8 | + "share_ge_5pct": 0.7851341455295919, | ||||
| 9 | + "share_ge_10pct": 0.6325144797283803 | ||||
| 10 | + }, | ||||
| 11 | + "gap_same_company": { | ||||
| 12 | + "n": 27939, | ||||
| 13 | + "median": 0.15128944754622609, | ||||
| 14 | + "p25": 0.057746822212323856, | ||||
| 15 | + "p75": 0.26844510500133234, | ||||
| 16 | + "mean": 0.18576393643510483, | ||||
| 17 | + "share_ge_5pct": 0.7821682952145746, | ||||
| 18 | + "share_ge_10pct": 0.6273667633057733 | ||||
| 19 | + }, | ||||
| 20 | + "gap_cross_company": { | ||||
| 21 | + "n": 2103, | ||||
| 22 | + "median": 0.18254405095440074, | ||||
| 23 | + "p25": 0.08017111858864046, | ||||
| 24 | + "p75": 0.3370446464822916, | ||||
| 25 | + "mean": 0.23722170356771002, | ||||
| 26 | + "share_ge_5pct": 0.8245363766048502, | ||||
| 27 | + "share_ge_10pct": 0.7009034712315739 | ||||
| 28 | + }, | ||||
| 29 | + "cv_all": { | ||||
| 30 | + "n": 30042, | ||||
| 31 | + "median": 0.07341658510817389, | ||||
| 32 | + "p25": 0.029429075927015894, | ||||
| 33 | + "p75": 0.13034226381318856, | ||||
| 34 | + "mean": 0.08951038693130896, | ||||
| 35 | + "share_ge_5pct": 0.6284867851674323, | ||||
| 36 | + "share_ge_10pct": 0.3644231409360229 | ||||
| 37 | + }, | ||||
| 38 | + "banner_fe_reference": "Metro", | ||||
| 39 | + "resid_sd": 0.11456460507159695, | ||||
| 40 | + "n_obs_fe": 67732, | ||||
| 41 | + "ar1_rho": 0.663411776499718, | ||||
| 42 | + "ar1_se": 0.013989188479587735, | ||||
| 43 | + "share_pairs_sign_stable_ge4w": 0.457002457002457, | ||||
| 44 | + "n_pairs_ge4w": 4884, | ||||
| 45 | + "ar1_same_company": [ | ||||
| 46 | + 0.6675381113692006, | ||||
| 47 | + 0.013865058825916333, | ||||
| 48 | + 0.45115007970849463, | ||||
| 49 | + 4391 | ||||
| 50 | + ], | ||||
| 51 | + "ar1_cross_company": [ | ||||
| 52 | + 0.6302714331760938, | ||||
| 53 | + 0.03769326769881746, | ||||
| 54 | + 0.5091277890466531, | ||||
| 55 | + 493 | ||||
| 56 | + ], | ||||
| 57 | + "placebo_ar1_mean": 0.0035750286222568906, | ||||
| 58 | + "placebo_ar1_sd": 0.011048954753771625 | ||||
| 59 | +} | ||||
| \ No newline at end of file | |||||
added
experiments/modelC_summary.json
+71 −0
@@ -0,0 +1,71 @@ | |||||
| 1 | +{ | ||||
| 2 | + "rival_all": { | ||||
| 3 | + "n": 22213, | ||||
| 4 | + "coefs": { | ||||
| 5 | + "rival_all": 0.036983611990011225, | ||||
| 6 | + "rival_all_l1": 0.03674370006018662, | ||||
| 7 | + "rival_all_l2": 0.014867128258090253, | ||||
| 8 | + "rival_all_l3": 0.04341238029758149, | ||||
| 9 | + "rival_all_f1": 0.008899934370312498, | ||||
| 10 | + "rival_all_f2": 0.013343162704560697 | ||||
| 11 | + }, | ||||
| 12 | + "ses": { | ||||
| 13 | + "rival_all": 0.018207550537640733, | ||||
| 14 | + "rival_all_l1": 0.012276621362321993, | ||||
| 15 | + "rival_all_l2": 0.013107075427690282, | ||||
| 16 | + "rival_all_l3": 0.014656351032885128, | ||||
| 17 | + "rival_all_f1": 0.012048599868225932, | ||||
| 18 | + "rival_all_f2": 0.014684867882272816 | ||||
| 19 | + }, | ||||
| 20 | + "P_own_change_given_rival_change_l1": 0.2527131782945736, | ||||
| 21 | + "P_own_change_given_no_rival_change_l1": 0.1711969770843491 | ||||
| 22 | + }, | ||||
| 23 | + "rival_same": { | ||||
| 24 | + "n": 21192, | ||||
| 25 | + "coefs": { | ||||
| 26 | + "rival_same": 0.027632240932606675, | ||||
| 27 | + "rival_same_l1": 0.04166870039441454, | ||||
| 28 | + "rival_same_l2": 0.01145626003174879, | ||||
| 29 | + "rival_same_l3": 0.034260009805555276, | ||||
| 30 | + "rival_same_f1": 0.0132625155508785, | ||||
| 31 | + "rival_same_f2": 0.014714728442878371 | ||||
| 32 | + }, | ||||
| 33 | + "ses": { | ||||
| 34 | + "rival_same": 0.01831796970903035, | ||||
| 35 | + "rival_same_l1": 0.01234955835151791, | ||||
| 36 | + "rival_same_l2": 0.013246905947066992, | ||||
| 37 | + "rival_same_l3": 0.015028588991760062, | ||||
| 38 | + "rival_same_f1": 0.012251592712678097, | ||||
| 39 | + "rival_same_f2": 0.014337631789336497 | ||||
| 40 | + }, | ||||
| 41 | + "P_own_change_given_rival_change_l1": 0.25367852486496556, | ||||
| 42 | + "P_own_change_given_no_rival_change_l1": 0.17588320798837137 | ||||
| 43 | + }, | ||||
| 44 | + "rival_cross": { | ||||
| 45 | + "n": 1784, | ||||
| 46 | + "coefs": { | ||||
| 47 | + "rival_cross": 0.08084154607798574, | ||||
| 48 | + "rival_cross_l1": -0.0012395703633851167, | ||||
| 49 | + "rival_cross_l2": -0.056863578932101456, | ||||
| 50 | + "rival_cross_l3": 0.046743672971606504, | ||||
| 51 | + "rival_cross_f1": -0.10799246348174829, | ||||
| 52 | + "rival_cross_f2": -0.064020894931646 | ||||
| 53 | + }, | ||||
| 54 | + "ses": { | ||||
| 55 | + "rival_cross": 0.06613440280922223, | ||||
| 56 | + "rival_cross_l1": 0.04779603312485358, | ||||
| 57 | + "rival_cross_l2": 0.028928467120645663, | ||||
| 58 | + "rival_cross_l3": 0.06013080127915635, | ||||
| 59 | + "rival_cross_f1": 0.048390891586986506, | ||||
| 60 | + "rival_cross_f2": 0.04882941084495431 | ||||
| 61 | + }, | ||||
| 62 | + "P_own_change_given_rival_change_l1": 0.2744360902255639, | ||||
| 63 | + "P_own_change_given_no_rival_change_l1": 0.18370607028753994 | ||||
| 64 | + }, | ||||
| 65 | + "promo_comovement": { | ||||
| 66 | + "group_weeks": 30042, | ||||
| 67 | + "share_any_low": 0.16027561414020372, | ||||
| 68 | + "share_two_plus_low": 0.017209240396777844, | ||||
| 69 | + "expected_two_plus_if_independent": 0.006207116594008007 | ||||
| 70 | + } | ||||
| 71 | +} | ||||
| \ No newline at end of file | |||||
added
experiments/modelD_summary.json
+59 −0
@@ -0,0 +1,59 @@ | |||||
| 1 | +{ | ||||
| 2 | + "storefront_posted": { | ||||
| 3 | + "naive_mean_change": 0.0036769234266957884, | ||||
| 4 | + "naive_median_change": 0.0, | ||||
| 5 | + "jevons_chained_change": 0.0028498796180160824, | ||||
| 6 | + "fixed_jevons_change": 0.000961438796386993, | ||||
| 7 | + "fixed_dutot_change": -0.0005377093740115413, | ||||
| 8 | + "fixed_carli_change": 0.026867158956678905, | ||||
| 9 | + "n_balanced": 44423, | ||||
| 10 | + "n_total_first_week": 51434 | ||||
| 11 | + }, | ||||
| 12 | + "storefront_unit": { | ||||
| 13 | + "naive_mean_change": -0.012446313570385947, | ||||
| 14 | + "naive_median_change": -0.006544530339934296, | ||||
| 15 | + "jevons_chained_change": 0.0027664755512584183, | ||||
| 16 | + "fixed_jevons_change": 0.0007395269633583279, | ||||
| 17 | + "fixed_dutot_change": -0.004284566992962091, | ||||
| 18 | + "fixed_carli_change": 0.03566130323976746, | ||||
| 19 | + "n_balanced": 29671, | ||||
| 20 | + "n_total_first_week": 36003 | ||||
| 21 | + }, | ||||
| 22 | + "all_channels_posted": { | ||||
| 23 | + "naive_mean_change": -0.02927666201945356, | ||||
| 24 | + "naive_median_change": 0.0, | ||||
| 25 | + "jevons_chained_change": 0.0023785412285990404, | ||||
| 26 | + "fixed_jevons_change": 0.0005360460992416272, | ||||
| 27 | + "fixed_dutot_change": -0.0008775599602732775, | ||||
| 28 | + "fixed_carli_change": 0.027756326994421432, | ||||
| 29 | + "n_balanced": 45214, | ||||
| 30 | + "n_total_first_week": 57942 | ||||
| 31 | + }, | ||||
| 32 | + "bootstrap": { | ||||
| 33 | + "gap_mean": 0.0006271123459269828, | ||||
| 34 | + "gap_ci95": [ | ||||
| 35 | + -0.007830383632211136, | ||||
| 36 | + 0.008431239690139423 | ||||
| 37 | + ], | ||||
| 38 | + "jevons_ci95": [ | ||||
| 39 | + 0.001509196870153784, | ||||
| 40 | + 0.004221513313892511 | ||||
| 41 | + ] | ||||
| 42 | + }, | ||||
| 43 | + "assortment": { | ||||
| 44 | + "listings": 71694, | ||||
| 45 | + "balanced": 44515, | ||||
| 46 | + "entrants": 20260, | ||||
| 47 | + "exits": 17295, | ||||
| 48 | + "weekly_entry_rate": 0.03595173117340451 | ||||
| 49 | + }, | ||||
| 50 | + "size_label_history_available": false, | ||||
| 51 | + "statcan": { | ||||
| 52 | + "cpi_qc_food_stores_aug": 194.4, | ||||
| 53 | + "sep": 193.1, | ||||
| 54 | + "cpi_change_aug_sep": -0.006687242798353976, | ||||
| 55 | + "ours_jevons_aug_sep": 0.000991433026574784, | ||||
| 56 | + "cpi_latest": "2026-09-01", | ||||
| 57 | + "cpi_yoy_sep": 0.016315789473684283 | ||||
| 58 | + } | ||||
| 59 | +} | ||||
| \ No newline at end of file | |||||
added
experiments/modelE_summary.json
+38 −0
@@ -0,0 +1,38 @@ | |||||
| 1 | +{ | ||||
| 2 | + "items_in_common_set_mean": 7.181818181818182, | ||||
| 3 | + "banners_available_mean": 7.7272727272727275, | ||||
| 4 | + "frontier_mean_cost_by_K": { | ||||
| 5 | + "1": 23.12194937122785, | ||||
| 6 | + "2": 20.888101262563136, | ||||
| 7 | + "3": 20.204123130937315, | ||||
| 8 | + "4": 20.054139457868775, | ||||
| 9 | + "5": 20.034210164939484, | ||||
| 10 | + "99": 20.028755619484937 | ||||
| 11 | + }, | ||||
| 12 | + "savings_unconstrained_vs_best_single": 3.0931937517429127, | ||||
| 13 | + "savings_share_unconstrained": 0.1337773775939487, | ||||
| 14 | + "share_of_unconstrained_savings_at_K2": 0.7221817603265276, | ||||
| 15 | + "single_banner_cost_spread_mean": 39.69805844961497, | ||||
| 16 | + "single_banner_mean_cost": 32.88295457993973, | ||||
| 17 | + "most_expensive_vs_cheapest_single_share": 1.856238474704022, | ||||
| 18 | + "travel_cost_sensitivity": [ | ||||
| 19 | + { | ||||
| 20 | + "fixed_cost": 5.0, | ||||
| 21 | + "n_used": 1.0, | ||||
| 22 | + "cost_goods": 18.12194937122785, | ||||
| 23 | + "cost_incl": 23.12194937122785 | ||||
| 24 | + }, | ||||
| 25 | + { | ||||
| 26 | + "fixed_cost": 10.0, | ||||
| 27 | + "n_used": 1.0, | ||||
| 28 | + "cost_goods": 13.12194937122785, | ||||
| 29 | + "cost_incl": 23.12194937122785 | ||||
| 30 | + }, | ||||
| 31 | + { | ||||
| 32 | + "fixed_cost": 20.0, | ||||
| 33 | + "n_used": 1.0, | ||||
| 34 | + "cost_goods": 3.121949371227848, | ||||
| 35 | + "cost_incl": 23.12194937122785 | ||||
| 36 | + } | ||||
| 37 | + ] | ||||
| 38 | +} | ||||
| \ No newline at end of file | |||||
added
experiments/modelF_summary.json
+104 −0
@@ -0,0 +1,104 @@ | |||||
| 1 | +{ | ||||
| 2 | + "summary": [ | ||||
| 3 | + { | ||||
| 4 | + "target": "log_price", | ||||
| 5 | + "model": "banner_cat_drift", | ||||
| 6 | + "origins": 4, | ||||
| 7 | + "n": 215264, | ||||
| 8 | + "mae": 0.024807352788469867, | ||||
| 9 | + "rmse": 0.10842948605770154, | ||||
| 10 | + "brier": NaN, | ||||
| 11 | + "auc": NaN | ||||
| 12 | + }, | ||||
| 13 | + { | ||||
| 14 | + "target": "log_price", | ||||
| 15 | + "model": "lightgbm", | ||||
| 16 | + "origins": 4, | ||||
| 17 | + "n": 215264, | ||||
| 18 | + "mae": 0.030072224891589998, | ||||
| 19 | + "rmse": 0.10001695748926878, | ||||
| 20 | + "brier": NaN, | ||||
| 21 | + "auc": NaN | ||||
| 22 | + }, | ||||
| 23 | + { | ||||
| 24 | + "target": "log_price", | ||||
| 25 | + "model": "mean_reversion_ref", | ||||
| 26 | + "origins": 4, | ||||
| 27 | + "n": 215264, | ||||
| 28 | + "mae": 0.02837850853015459, | ||||
| 29 | + "rmse": 0.10249483142934793, | ||||
| 30 | + "brier": NaN, | ||||
| 31 | + "auc": NaN | ||||
| 32 | + }, | ||||
| 33 | + { | ||||
| 34 | + "target": "log_price", | ||||
| 35 | + "model": "naive_last", | ||||
| 36 | + "origins": 4, | ||||
| 37 | + "n": 215264, | ||||
| 38 | + "mae": 0.022605082941485766, | ||||
| 39 | + "rmse": 0.10825508687878052, | ||||
| 40 | + "brier": NaN, | ||||
| 41 | + "auc": NaN | ||||
| 42 | + }, | ||||
| 43 | + { | ||||
| 44 | + "target": "log_price", | ||||
| 45 | + "model": "ridge", | ||||
| 46 | + "origins": 4, | ||||
| 47 | + "n": 215264, | ||||
| 48 | + "mae": 0.029885922259458478, | ||||
| 49 | + "rmse": 0.09917790589117646, | ||||
| 50 | + "brier": NaN, | ||||
| 51 | + "auc": NaN | ||||
| 52 | + }, | ||||
| 53 | + { | ||||
| 54 | + "target": "promotion", | ||||
| 55 | + "model": "base_rate", | ||||
| 56 | + "origins": 4, | ||||
| 57 | + "n": 215264, | ||||
| 58 | + "mae": NaN, | ||||
| 59 | + "rmse": NaN, | ||||
| 60 | + "brier": 0.0568232667303276, | ||||
| 61 | + "auc": 0.5 | ||||
| 62 | + }, | ||||
| 63 | + { | ||||
| 64 | + "target": "promotion", | ||||
| 65 | + "model": "hist_rate", | ||||
| 66 | + "origins": 4, | ||||
| 67 | + "n": 215264, | ||||
| 68 | + "mae": NaN, | ||||
| 69 | + "rmse": NaN, | ||||
| 70 | + "brier": 0.057192760775280654, | ||||
| 71 | + "auc": 0.6778171962496753 | ||||
| 72 | + }, | ||||
| 73 | + { | ||||
| 74 | + "target": "promotion", | ||||
| 75 | + "model": "lightgbm", | ||||
| 76 | + "origins": 4, | ||||
| 77 | + "n": 215264, | ||||
| 78 | + "mae": NaN, | ||||
| 79 | + "rmse": NaN, | ||||
| 80 | + "brier": 0.03127230289344953, | ||||
| 81 | + "auc": 0.9359091370790923 | ||||
| 82 | + }, | ||||
| 83 | + { | ||||
| 84 | + "target": "promotion", | ||||
| 85 | + "model": "logit", | ||||
| 86 | + "origins": 4, | ||||
| 87 | + "n": 215264, | ||||
| 88 | + "mae": NaN, | ||||
| 89 | + "rmse": NaN, | ||||
| 90 | + "brier": 0.04392957451269847, | ||||
| 91 | + "auc": 0.8939986946425332 | ||||
| 92 | + }, | ||||
| 93 | + { | ||||
| 94 | + "target": "promotion", | ||||
| 95 | + "model": "persist", | ||||
| 96 | + "origins": 4, | ||||
| 97 | + "n": 215264, | ||||
| 98 | + "mae": NaN, | ||||
| 99 | + "rmse": NaN, | ||||
| 100 | + "brier": 0.050992947231181564, | ||||
| 101 | + "auc": 0.7518564786841642 | ||||
| 102 | + } | ||||
| 103 | + ] | ||||
| 104 | +} | ||||
| \ No newline at end of file | |||||
modified
pyproject.toml
+1 −1
@@ -14,7 +14,7 @@ dependencies = [ | |||||
| 14 | "lightgbm>=4.3", | 14 | "lightgbm>=4.3", | ||
| 15 | "matplotlib>=3.9", | 15 | "matplotlib>=3.9", | ||
| 16 | "duckdb>=1.0", | 16 | "duckdb>=1.0", | ||
| 17 | − "pulp>=2.8", | 17 | + "pulp>=2.8,<3", | ||
| 18 | "pyyaml>=6.0", | 18 | "pyyaml>=6.0", | ||
| 19 | "pytest>=8.0", | 19 | "pytest>=8.0", | ||
| 20 | "requests>=2.32", | 20 | "requests>=2.32", | ||
modified
requirements-lock.txt
+1 −1
@@ -57,7 +57,7 @@ pluggy==1.7.0 | |||||
| 57 | prompt-toolkit==3.0.53 | 57 | prompt-toolkit==3.0.53 | ||
| 58 | psutil==7.2.2 | 58 | psutil==7.2.2 | ||
| 59 | ptyprocess==0.7.0 | 59 | ptyprocess==0.7.0 | ||
| 60 | −pulp==4.2.0 | 60 | +pulp==2.9.0 | ||
| 61 | pure-eval==0.2.4 | 61 | pure-eval==0.2.4 | ||
| 62 | pyarrow==26.0.0 | 62 | pyarrow==26.0.0 | ||
| 63 | pycparser==3.12 | 63 | pycparser==3.12 | ||
added
src/econometrics/__init__.py
+0 −0
added
src/econometrics/build_matched_panel.py
+58 −0
@@ -0,0 +1,58 @@ | |||||
| 1 | +"""Phase 4.0 — analysis-ready weekly panels (preregistration §1). | ||||
| 2 | + | ||||
| 3 | +Outputs (data/processed): | ||||
| 4 | + panel_weekly.parquet — grocery-universe listing × complete circular week, posted price = weekly mode, unit prices, | ||||
| 5 | + banner/company/channel/category, size, flags | ||||
| 6 | + panel_matched_<TIERS>.parquet — panel_weekly restricted to listings in cross-banner match groups (A, AB, ABC, ABCD), | ||||
| 7 | + with group_id and `ambiguous` flag (group has >1 simultaneously posted listing from the | ||||
| 8 | + same banner-channel in that week) | ||||
| 9 | +""" | ||||
| 10 | +import numpy as np, pandas as pd | ||||
| 11 | +from src.common import path, get_logger, ledger, SETTINGS, write_manifest | ||||
| 12 | +log = get_logger("panel4") | ||||
| 13 | +P = path("processed") | ||||
| 14 | + | ||||
| 15 | +def main(): | ||||
| 16 | + wk = pd.read_parquet(P / "offers_weekly.parquet") | ||||
| 17 | + prod = pd.read_parquet(P / "products.parquet") | ||||
| 18 | + ret = pd.read_parquet(P / "retailers.parquet") | ||||
| 19 | + prod = prod.merge(ret[["feed_id", "banner", "company", "channel", "store_format", "is_grocery"]], left_on="source", right_on="feed_id", how="left") | ||||
| 20 | + sc = SETTINGS["scope"] | ||||
| 21 | + uni = prod[(~prod.source.isin(sc["exclude_sources"])) & prod.is_grocery & (~prod.category.isin(sc["non_grocery_categories"])) & (~prod.flag_unrealistic)] | ||||
| 22 | + log.info("grocery universe listings: %d (of %d)", len(uni), len(prod)) | ||||
| 23 | + weeks = sorted(wk.week.unique()) | ||||
| 24 | + complete = [w for w in weeks if w >= pd.Timestamp("2026-08-13").date() and w <= pd.Timestamp("2026-10-22").date()] | ||||
| 25 | + pw = wk[wk.week.isin(complete) & wk.uid.isin(uni.uid)].copy() | ||||
| 26 | + cols = ["uid", "source", "banner", "company", "channel", "store_format", "category", "name", "brand", "size_label", "size_qty", "size_base", "size_pack", | ||||
| 27 | + "per_weight_label", "flag_weighted", "flag_multibuy", "flag_loyalty", "flag_online_only", "gtin_valid", "active", "on_sale", "regular_price", "price"] | ||||
| 28 | + pw = pw.merge(uni[cols].rename(columns={"source": "source_p", "price": "price_now", "on_sale": "on_sale_now", "regular_price": "regular_now"}), on="uid", how="left").drop(columns=["source_p"]) | ||||
| 29 | + pw["week"] = pd.to_datetime(pw.week); pw["week_idx"] = pw.week.rank(method="dense").astype(int) | ||||
| 30 | + pw["log_p"] = np.log(pw.p_mode) | ||||
| 31 | + qty = pw.size_qty.where(pw.size_base != "un", pw.size_qty) | ||||
| 32 | + pw["unit_p"] = np.where(pw.size_base == "un", pw.p_mode / pw.size_qty, pw.p_mode / pw.size_qty * 100.0) | ||||
| 33 | + pw.loc[pw.per_weight_label.eq("kg"), "unit_p"] = pw.p_mode / 10.0 | ||||
| 34 | + pw.loc[pw.per_weight_label.eq("lb"), "unit_p"] = pw.p_mode / 4.53592 | ||||
| 35 | + pw["unit_base"] = np.where(pw.size_base == "un", "un", np.where(pw.size_base == "ml", "100 ml", "100 g")) | ||||
| 36 | + pw.loc[pw.per_weight_label.isin(["kg", "lb"]), "unit_base"] = "100 g" | ||||
| 37 | + pw["log_unit_p"] = np.log(pw.unit_p) | ||||
| 38 | + pw["multi_price_week"] = pw.n_distinct > 1 | ||||
| 39 | + pw.to_parquet(P / "panel_weekly.parquet", index=False) | ||||
| 40 | + log.info("panel_weekly: %d listing-weeks, %d listings, %d weeks (%s → %s), %d banners", len(pw), pw.uid.nunique(), pw.week.nunique(), pw.week.min().date(), pw.week.max().date(), pw.banner.nunique()) | ||||
| 41 | + files = [P / "panel_weekly.parquet"] | ||||
| 42 | + for tiers in ["A", "AB", "ABC", "ABCD"]: | ||||
| 43 | + g = pd.read_parquet(P / f"match_groups_{tiers}.parquet")[["group_id", "uid"]] | ||||
| 44 | + pm = pw.merge(g, on="uid", how="inner") | ||||
| 45 | + # keep group-weeks with >= 2 banners posting | ||||
| 46 | + nb = pm.groupby(["group_id", "week"]).banner.transform("nunique"); pm = pm[nb >= 2].copy() | ||||
| 47 | + dup = pm.groupby(["group_id", "week", "banner", "channel"]).uid.transform("size") > 1 | ||||
| 48 | + amb = pm.assign(d=dup).groupby(["group_id", "week"]).d.transform("max"); pm["ambiguous"] = amb.astype(bool) | ||||
| 49 | + pm["same_company_group"] = pm.groupby(["group_id", "week"]).company.transform("nunique") == 1 | ||||
| 50 | + pm.to_parquet(P / f"panel_matched_{tiers}.parquet", index=False); files.append(P / f"panel_matched_{tiers}.parquet") | ||||
| 51 | + log.info("panel_matched_%-4s: %d listing-weeks, %d groups, %d group-weeks, ambiguous group-weeks %.1f%%, same-company-only group-weeks %.1f%%", | ||||
| 52 | + tiers, len(pm), pm.group_id.nunique(), pm.groupby(["group_id", "week"]).ngroups, | ||||
| 53 | + 100 * pm.drop_duplicates(["group_id", "week"]).ambiguous.mean(), 100 * pm.drop_duplicates(["group_id", "week"]).same_company_group.mean()) | ||||
| 54 | + write_manifest("panel4", files) | ||||
| 55 | + ledger("4", "build_matched_panel", f"{len(pw)} listing-weeks; weeks {pw.week.min().date()}..{pw.week.max().date()}") | ||||
| 56 | + | ||||
| 57 | +if __name__ == "__main__": | ||||
| 58 | + main() | ||||
added
src/econometrics/model_a_dispersion.py
+108 −0
@@ -0,0 +1,108 @@ | |||||
| 1 | +"""Model A — matched-product cross-banner price dispersion (H1). Preregistration §2 H1. | ||||
| 2 | + | ||||
| 3 | +Outputs: data/processed/modelA_*.csv and experiments/modelA_summary.json | ||||
| 4 | +""" | ||||
| 5 | +import json, re, numpy as np, pandas as pd, pyfixest as pf | ||||
| 6 | +from src.common import path, ROOT, get_logger, ledger, seed_everything, SEED | ||||
| 7 | +log = get_logger("modelA"); P = path("processed"); E = ROOT / "experiments"; E.mkdir(exist_ok=True) | ||||
| 8 | + | ||||
| 9 | +def group_week_stats(pm): | ||||
| 10 | + g = pm.groupby(["group_id", "week"]) | ||||
| 11 | + st = g.agg(n_banners=("banner", "nunique"), n_listings=("uid", "size"), gap=("log_p", lambda s: s.max() - s.min()), | ||||
| 12 | + cv=("p_mode", lambda s: s.std(ddof=0) / s.mean()), iqr=("log_p", lambda s: s.quantile(.75) - s.quantile(.25)), | ||||
| 13 | + same_company=("company", lambda s: s.nunique() == 1), category=("category", "first"), ambiguous=("ambiguous", "max")).reset_index() | ||||
| 14 | + return st | ||||
| 15 | + | ||||
| 16 | +def pair_gaps(pm): | ||||
| 17 | + """Banner-pair log gaps per group-week (one listing per banner-channel; ambiguous group-weeks dropped upstream).""" | ||||
| 18 | + rows = [] | ||||
| 19 | + for (gid, w), g in pm.groupby(["group_id", "week"]): | ||||
| 20 | + g = g.drop_duplicates("banner") | ||||
| 21 | + b = g.set_index("banner") | ||||
| 22 | + bs = sorted(b.index) | ||||
| 23 | + for i in range(len(bs)): | ||||
| 24 | + for j in range(i + 1, len(bs)): | ||||
| 25 | + rows.append((gid, w, bs[i], bs[j], b.at[bs[i], "log_p"] - b.at[bs[j], "log_p"], b.at[bs[i], "company"] == b.at[bs[j], "company"], g.category.iloc[0])) | ||||
| 26 | + return pd.DataFrame(rows, columns=["group_id", "week", "banner_r", "banner_s", "gap", "same_company", "category"]) | ||||
| 27 | + | ||||
| 28 | +def persistence(pg): | ||||
| 29 | + pg = pg.sort_values(["group_id", "banner_r", "banner_s", "week"]) | ||||
| 30 | + pg["gap_lag"] = pg.groupby(["group_id", "banner_r", "banner_s"]).gap.shift(1) | ||||
| 31 | + pg["week_lag"] = pg.groupby(["group_id", "banner_r", "banner_s"]).week.shift(1) | ||||
| 32 | + ok = pg[(pg.week - pg.week_lag).dt.days == 7].dropna(subset=["gap_lag"]) | ||||
| 33 | + fit = pf.feols("gap ~ gap_lag", data=ok, vcov={"CRV1": "group_id"}) | ||||
| 34 | + rho = float(fit.coef()["gap_lag"]); se = float(fit.se()["gap_lag"]) | ||||
| 35 | + sign = pg.dropna(subset=["gap"]).groupby(["group_id", "banner_r", "banner_s"]).gap.agg(lambda s: (np.sign(s).nunique() == 1) and (s.abs() > 1e-9).all() and len(s) >= 4) | ||||
| 36 | + nflip = pg.groupby(["group_id", "banner_r", "banner_s"]).gap.agg(lambda s: ((np.sign(s) != np.sign(s.shift(1))) & s.shift(1).notna()).sum()) | ||||
| 37 | + nweeks = pg.groupby(["group_id", "banner_r", "banner_s"]).size() | ||||
| 38 | + stable = sign[nweeks >= 4].mean() | ||||
| 39 | + return rho, se, float(stable), int((nweeks >= 4).sum()), ok | ||||
| 40 | + | ||||
| 41 | +def main(): | ||||
| 42 | + seed_everything() | ||||
| 43 | + out = {} | ||||
| 44 | + pm_all = pd.read_parquet(P / "panel_matched_AB.parquet") | ||||
| 45 | + pm = pm_all[(pm_all.channel == "online_storefront") & (~pm_all.ambiguous)].copy() | ||||
| 46 | + nb = pm.groupby(["group_id", "week"]).banner.transform("nunique"); pm = pm[nb >= 2] | ||||
| 47 | + log.info("H1 primary sample: %d listing-weeks, %d groups, %d group-weeks", len(pm), pm.group_id.nunique(), pm.groupby(["group_id", "week"]).ngroups) | ||||
| 48 | + st = group_week_stats(pm); st.to_csv(P / "modelA_groupweek_stats.csv", index=False) | ||||
| 49 | + def q(s): return dict(n=int(len(s)), median=float(s.median()), p25=float(s.quantile(.25)), p75=float(s.quantile(.75)), mean=float(s.mean()), share_ge_5pct=float((s >= 0.05).mean()), share_ge_10pct=float((s >= 0.10).mean())) | ||||
| 50 | + out["gap_all"] = q(st.gap); out["gap_same_company"] = q(st[st.same_company].gap); out["gap_cross_company"] = q(st[~st.same_company].gap) | ||||
| 51 | + out["cv_all"] = q(st.cv) | ||||
| 52 | + bycat = st.groupby("category").gap.agg(["size", "median", "mean"]).reset_index().sort_values("median", ascending=False); bycat.to_csv(P / "modelA_gap_by_category.csv", index=False) | ||||
| 53 | + # Banner fixed effects: log_p ~ 1 | group_week + banner (reference banner = most frequent) | ||||
| 54 | + pm["gw"] = pm.group_id + "_" + pm.week.dt.strftime("%Y%m%d") | ||||
| 55 | + ref = pm.banner.value_counts().index[0] | ||||
| 56 | + pm["banner_f"] = pd.Categorical(pm.banner, categories=[ref] + sorted(set(pm.banner) - {ref})) | ||||
| 57 | + fit = pf.feols("log_p ~ i(banner_f, ref=" + repr(ref) + ") | gw", data=pm, vcov={"CRV1": "group_id"}) | ||||
| 58 | + co = fit.coef(); se = fit.se() | ||||
| 59 | + fe = pd.DataFrame({"term": co.index, "coef": co.values, "se": se.values}); fe["banner"] = fe.term.map(lambda t: re.sub(r"^.*?::|^.*\[T\.|\]$", "", t)) | ||||
| 60 | + fe["n_listing_weeks"] = fe.banner.map(pm.banner.value_counts()); fe = fe.sort_values("coef"); fe.to_csv(P / "modelA_banner_effects.csv", index=False) | ||||
| 61 | + out["banner_fe_reference"] = ref; out["resid_sd"] = float(np.sqrt(np.mean(fit.resid() ** 2))); out["n_obs_fe"] = int(fit._N) | ||||
| 62 | + # two-way cluster sensitivity (group + banner) | ||||
| 63 | + try: | ||||
| 64 | + fit2 = pf.feols("log_p ~ i(banner_f, ref=" + repr(ref) + ") | gw", data=pm, vcov={"CRV1": "group_id + banner"}) | ||||
| 65 | + fe["se_twoway"] = fe.term.map(fit2.se()); fe.to_csv(P / "modelA_banner_effects.csv", index=False) | ||||
| 66 | + except Exception as e: | ||||
| 67 | + log.warning("two-way clustering failed: %s", e) | ||||
| 68 | + # Persistence | ||||
| 69 | + pg = pair_gaps(pm); pg.to_parquet(P / "modelA_pair_gaps.parquet", index=False) | ||||
| 70 | + rho, se_rho, stable, npairs, ok = persistence(pg) | ||||
| 71 | + out["ar1_rho"] = rho; out["ar1_se"] = se_rho; out["share_pairs_sign_stable_ge4w"] = stable; out["n_pairs_ge4w"] = npairs | ||||
| 72 | + rho_same, se_same, st_same, n_same, _ = persistence(pg[pg.same_company]); rho_x, se_x, st_x, n_x, _ = persistence(pg[~pg.same_company]) | ||||
| 73 | + out["ar1_same_company"] = [rho_same, se_same, st_same, n_same]; out["ar1_cross_company"] = [rho_x, se_x, st_x, n_x] | ||||
| 74 | + # Placebo: permute banner labels within group-week, recompute AR(1) | ||||
| 75 | + rng = np.random.default_rng(SEED); rhos = [] | ||||
| 76 | + for b in range(20): | ||||
| 77 | + pp = pm.copy(); pp["banner"] = pp.groupby(["group_id", "week"]).banner.transform(lambda s: rng.permutation(s.values)) | ||||
| 78 | + pgp = pair_gaps(pp); r, _, _, _, _ = persistence(pgp); rhos.append(r) | ||||
| 79 | + out["placebo_ar1_mean"] = float(np.mean(rhos)); out["placebo_ar1_sd"] = float(np.std(rhos)) | ||||
| 80 | + # Banner-pair table (median gap and persistence) for the heatmap | ||||
| 81 | + pairtab = pg.groupby(["banner_r", "banner_s"]).agg(n=("gap", "size"), median_gap=("gap", "median"), mean_gap=("gap", "mean"), share_r_cheaper=("gap", lambda s: (s < 0).mean())).reset_index() | ||||
| 82 | + pairtab.to_csv(P / "modelA_banner_pairs.csv", index=False) | ||||
| 83 | + # Within-banner store-mixture bound (storefront, grocery universe) | ||||
| 84 | + pw = pd.read_parquet(P / "panel_weekly.parquet", columns=["banner", "channel", "n_distinct", "rel_range", "p_min", "p_max", "p_mode"]) | ||||
| 85 | + sf = pw[pw.channel == "online_storefront"] | ||||
| 86 | + sm = sf.groupby("banner").agg(listing_weeks=("rel_range", "size"), share_multi=("n_distinct", lambda s: (s > 1).mean()), | ||||
| 87 | + median_range_when_multi=("rel_range", lambda s: s[s > 0].median()), median_log_range_multi=("p_max", lambda s: np.nan)).reset_index() | ||||
| 88 | + sf2 = sf[sf.n_distinct > 1].assign(lr=lambda d: np.log(d.p_max / d.p_min)); sm["median_log_range_multi"] = sm.banner.map(sf2.groupby("banner").lr.median()) | ||||
| 89 | + sm.sort_values("share_multi", ascending=False).to_csv(P / "modelA_within_banner_mixture.csv", index=False) | ||||
| 90 | + # Sensitivity: tiers and price definitions | ||||
| 91 | + sens = [] | ||||
| 92 | + for tiers in ["A", "AB", "ABC", "ABCD"]: | ||||
| 93 | + for pdef in ["p_mode", "p_min", "p_max", "p_twmean"]: | ||||
| 94 | + x = pd.read_parquet(P / f"panel_matched_{tiers}.parquet"); x = x[(x.channel == "online_storefront") & (~x.ambiguous)] | ||||
| 95 | + x = x[x.groupby(["group_id", "week"]).banner.transform("nunique") >= 2] | ||||
| 96 | + lg = np.log(x[pdef]); gap = lg.groupby([x.group_id, x.week]).agg(lambda s: s.max() - s.min()) | ||||
| 97 | + sens.append(dict(tiers=tiers, price_def=pdef, group_weeks=len(gap), median_gap=gap.median(), share_ge_5pct=(gap >= .05).mean())) | ||||
| 98 | + for inc in ["with_circulars"]: | ||||
| 99 | + x = pm_all[~pm_all.ambiguous]; x = x[x.groupby(["group_id", "week"]).banner.transform("nunique") >= 2] | ||||
| 100 | + gap = x.groupby(["group_id", "week"]).log_p.agg(lambda s: s.max() - s.min()); sens.append(dict(tiers="AB", price_def="p_mode+circulars", group_weeks=len(gap), median_gap=gap.median(), share_ge_5pct=(gap >= .05).mean())) | ||||
| 101 | + pd.DataFrame(sens).to_csv(P / "modelA_sensitivity.csv", index=False) | ||||
| 102 | + (E / "modelA_summary.json").write_text(json.dumps(out, indent=2, default=float)) | ||||
| 103 | + log.info("H1: median gap %.3f (same-co %.3f, cross-co %.3f); share>=5%% %.2f; AR1 %.3f (se %.3f), placebo %.3f; sign-stable pairs %.2f (n=%d)", | ||||
| 104 | + out["gap_all"]["median"], out["gap_same_company"]["median"], out["gap_cross_company"]["median"], out["gap_all"]["share_ge_5pct"], rho, se_rho, out["placebo_ar1_mean"], stable, npairs) | ||||
| 105 | + ledger("4", "model_a", f"median gap {out['gap_all']['median']:.3f}; AR1 {rho:.3f}") | ||||
| 106 | + | ||||
| 107 | +if __name__ == "__main__": | ||||
| 108 | + main() | ||||
added
src/econometrics/model_b_promotions.py
+134 −0
@@ -0,0 +1,134 @@ | |||||
| 1 | +"""Model B — promotion dynamics (H2, H3). Preregistration §2 H2–H3. | ||||
| 2 | + | ||||
| 3 | +Storefront promotion state is inferred: week promoted if p_mode <= 0.90 x reference, reference = modal p_mode over the | ||||
| 4 | +trailing 8 weeks (incl. current). Circular items carry observed regular prices (original_price) and are analysed separately. | ||||
| 5 | +""" | ||||
| 6 | +import json, numpy as np, pandas as pd, pyfixest as pf | ||||
| 7 | +from scipy import stats | ||||
| 8 | +from src.common import path, ROOT, get_logger, ledger, seed_everything, SEED | ||||
| 9 | +log = get_logger("modelB"); P = path("processed"); E = ROOT / "experiments" | ||||
| 10 | + | ||||
| 11 | +def rolling_mode(vals, window=8): | ||||
| 12 | + out = [] | ||||
| 13 | + for i in range(len(vals)): | ||||
| 14 | + w = vals[max(0, i - window + 1): i + 1] | ||||
| 15 | + vc = pd.Series(w).value_counts() | ||||
| 16 | + top = vc[vc == vc.max()].index | ||||
| 17 | + out.append(max(top)) # ties -> highest price (reference = regular) | ||||
| 18 | + return out | ||||
| 19 | + | ||||
| 20 | +def main(): | ||||
| 21 | + seed_everything(); out = {} | ||||
| 22 | + pw = pd.read_parquet(P / "panel_weekly.parquet") | ||||
| 23 | + sf = pw[pw.channel == "online_storefront"].sort_values(["uid", "week"]).copy() | ||||
| 24 | + nw = sf.groupby("uid").week.transform("nunique"); sf = sf[nw >= 8].copy() | ||||
| 25 | + log.info("H2/H3 storefront sample: %d listing-weeks, %d listings, %d banners", len(sf), sf.uid.nunique(), sf.banner.nunique()) | ||||
| 26 | + sf["ref"] = sf.groupby("uid").p_mode.transform(lambda s: pd.Series(rolling_mode(s.tolist()), index=s.index)) | ||||
| 27 | + sf["promo"] = sf.p_mode <= 0.90 * sf.ref | ||||
| 28 | + sf["depth"] = np.where(sf.promo, np.log(sf.ref / sf.p_mode), 0.0) | ||||
| 29 | + sf["log_ref"] = np.log(sf.ref) | ||||
| 30 | + sf["dlog_p"] = sf.groupby("uid").log_p.diff(); sf["dlog_ref"] = sf.groupby("uid").log_ref.diff() | ||||
| 31 | + sf["promo_lag"] = sf.groupby("uid").promo.shift(1) | ||||
| 32 | + # validation vs current on_sale flag (listings active now; last complete week) | ||||
| 33 | + last = sf[sf.week == sf.week.max()]; last = last[last.active == 1] | ||||
| 34 | + agree = (last.promo == (last.on_sale_now == 1)).mean() | ||||
| 35 | + out["promo_vs_on_sale_now_agreement"] = float(agree); out["promo_rate_last_week"] = float(last.promo.mean()); out["on_sale_now_rate"] = float((last.on_sale_now == 1).mean()) | ||||
| 36 | + out["promo_share_listing_weeks"] = float(sf.promo.mean()); out["promo_depth_median"] = float(sf.loc[sf.promo, "depth"].median()); out["promo_depth_mean"] = float(sf.loc[sf.promo, "depth"].mean()) | ||||
| 37 | + # H2: volatility raw vs reference | ||||
| 38 | + chg = sf.dropna(subset=["dlog_p"]) | ||||
| 39 | + vol = chg.groupby("banner").agg(n=("dlog_p", "size"), sd_raw=("dlog_p", "std"), sd_ref=("dlog_ref", "std"), freq_change=("dlog_p", lambda s: (s.abs() > 1e-9).mean()), | ||||
| 40 | + freq_ref_change=("dlog_ref", lambda s: (s.abs() > 1e-9).mean()), | ||||
| 41 | + share_changes_promo=("promo", lambda s: np.nan)).reset_index() | ||||
| 42 | + chg["is_change"] = chg.dlog_p.abs() > 1e-9; chg["promo_transition"] = chg.promo != chg.promo_lag | ||||
| 43 | + vol["share_changes_promo"] = vol.banner.map(chg[chg.is_change].groupby("banner").promo_transition.mean()) | ||||
| 44 | + vol["ratio_raw_ref"] = vol.sd_raw / vol.sd_ref; vol.sort_values("n", ascending=False).to_csv(P / "modelB_volatility_by_banner.csv", index=False) | ||||
| 45 | + volc = chg.groupby("category").agg(n=("dlog_p", "size"), sd_raw=("dlog_p", "std"), sd_ref=("dlog_ref", "std")).reset_index(); volc["ratio"] = volc.sd_raw / volc.sd_ref | ||||
| 46 | + volc.to_csv(P / "modelB_volatility_by_category.csv", index=False) | ||||
| 47 | + out["sd_raw_all"] = float(chg.dlog_p.std()); out["sd_ref_all"] = float(chg.dlog_ref.std()); out["ratio_all"] = out["sd_raw_all"] / out["sd_ref_all"] | ||||
| 48 | + out["share_weekly_changes_promo_transition"] = float(chg[chg.is_change].promo_transition.mean()); out["freq_weekly_change"] = float(chg.is_change.mean()) | ||||
| 49 | + # bootstrap over listings for the ratio | ||||
| 50 | + rng = np.random.default_rng(SEED); uids = chg.uid.unique(); byuid = {u: g for u, g in chg.groupby("uid")} | ||||
| 51 | + rat = [] | ||||
| 52 | + for b in range(300): | ||||
| 53 | + samp = rng.choice(uids, size=len(uids), replace=True); d = pd.concat([byuid[u] for u in samp]) | ||||
| 54 | + rat.append(d.dlog_p.std() / d.dlog_ref.std()) | ||||
| 55 | + out["ratio_ci95"] = [float(np.percentile(rat, 2.5)), float(np.percentile(rat, 97.5))] | ||||
| 56 | + # Falsification: single-store online shops (Shopify/WooCommerce) vs anti-bot storefronts — share of multi-price weeks | ||||
| 57 | + single = ["giant_tiger", "pa", "epipresto", "nuvo", "boite_a_grains", "bocoboco", "aliments_merci", "akhavan", "mayrand", "aubut", "maturin", "tau", "loco", "avril"] | ||||
| 58 | + out["multi_price_week_share_single_store"] = float(sf[sf.source.isin(single)].multi_price_week.mean()); out["multi_price_week_share_antibot"] = float(sf[~sf.source.isin(single)].multi_price_week.mean()) | ||||
| 59 | + # Transition matrix | ||||
| 60 | + tm = pd.crosstab(sf.promo_lag.dropna().astype(bool), sf.loc[sf.promo_lag.notna(), "promo"], normalize="index"); tm.to_csv(P / "modelB_transition_matrix.csv") | ||||
| 61 | + out["P_stay_promo"] = float(tm.loc[True, True]) if True in tm.index else None; out["P_enter_promo"] = float(tm.loc[False, True]) if False in tm.index else None | ||||
| 62 | + # Episodes | ||||
| 63 | + eps = [] | ||||
| 64 | + for uid, g in sf.groupby("uid"): | ||||
| 65 | + g = g.reset_index(drop=True); pr = g.promo.values; i = 0 | ||||
| 66 | + while i < len(pr): | ||||
| 67 | + if pr[i]: | ||||
| 68 | + j = i | ||||
| 69 | + while j + 1 < len(pr) and pr[j + 1]: j += 1 | ||||
| 70 | + censored_start = i == 0; censored_end = j == len(pr) - 1 | ||||
| 71 | + pre_ref = g.ref.iloc[i - 1] if i > 0 else np.nan; post_ref = g.ref.iloc[j + 1] if j + 1 < len(pr) else np.nan | ||||
| 72 | + post_p = g.p_mode.iloc[j + 1] if j + 1 < len(pr) else np.nan | ||||
| 73 | + eps.append(dict(uid=uid, banner=g.banner.iloc[0], category=g.category.iloc[0], start=g.week.iloc[i], end=g.week.iloc[j], duration=j - i + 1, | ||||
| 74 | + depth=g.depth.iloc[i:j + 1].mean(), censored=censored_start or censored_end, pre_ref=pre_ref, post_ref=post_ref, post_p=post_p, | ||||
| 75 | + dlog_ref=np.log(post_ref / pre_ref) if pre_ref and post_ref and not (censored_start or censored_end) else np.nan, | ||||
| 76 | + dlog_post_price_vs_pre_ref=np.log(post_p / pre_ref) if pre_ref and post_p and not (censored_start or censored_end) else np.nan)) | ||||
| 77 | + i = j + 1 | ||||
| 78 | + else: i += 1 | ||||
| 79 | + ep = pd.DataFrame(eps); ep.to_parquet(P / "modelB_episodes.parquet", index=False) | ||||
| 80 | + unc = ep[~ep.censored] | ||||
| 81 | + out["n_episodes"] = int(len(ep)); out["n_uncensored"] = int(len(unc)); out["duration_median_unc"] = float(unc.duration.median()); out["duration_mean_unc"] = float(unc.duration.mean()) | ||||
| 82 | + out["share_duration_1w"] = float((unc.duration == 1).mean()); out["depth_median_unc"] = float(unc.depth.median()) | ||||
| 83 | + rec = ep.sort_values(["uid", "start"]); rec["prev_end"] = rec.groupby("uid").end.shift(1); rec["recur_weeks"] = (rec.start - rec.prev_end).dt.days / 7 | ||||
| 84 | + out["recurrence_median_weeks"] = float(rec.recur_weeks.dropna().median()) if rec.recur_weeks.notna().any() else None; out["share_listings_with_2plus_episodes"] = float((ep.groupby("uid").size() >= 2).mean()) | ||||
| 85 | + # H3: reference change after episode | ||||
| 86 | + d = unc.dlog_ref.dropna(); out["H3_dlog_ref_mean"] = float(d.mean()); out["H3_dlog_ref_se"] = float(d.std() / np.sqrt(len(d))); out["H3_n"] = int(len(d)) | ||||
| 87 | + out["H3_share_ref_up"] = float((d > 1e-9).mean()); out["H3_share_ref_down"] = float((d < -1e-9).mean()); out["H3_share_ref_same"] = float((d.abs() <= 1e-9).mean()) | ||||
| 88 | + d2 = unc.dlog_post_price_vs_pre_ref.dropna(); out["H3_post_price_vs_pre_ref_mean"] = float(d2.mean()); out["H3_post_price_vs_pre_ref_share_up"] = float((d2 > 1e-9).mean()) | ||||
| 89 | + # Event study around episode end (weeks -4..+4), listing FE + week FE; pretrend joint test | ||||
| 90 | + ev = [] | ||||
| 91 | + for _, e in unc.iterrows(): | ||||
| 92 | + g = sf[sf.uid == e.uid] | ||||
| 93 | + for k in range(-4, 5): | ||||
| 94 | + wk = e.end + pd.Timedelta(weeks=k) | ||||
| 95 | + r = g[g.week == wk] | ||||
| 96 | + if len(r): ev.append(dict(uid=e.uid, week=wk, k=k, log_p=float(r.log_p.iloc[0]), log_ref=float(r.log_ref.iloc[0]))) | ||||
| 97 | + ev = pd.DataFrame(ev) | ||||
| 98 | + if len(ev): | ||||
| 99 | + ev["k_f"] = ev.k.astype(int) | ||||
| 100 | + fit = pf.feols("log_p ~ i(k_f, ref=-1) | uid + week", data=ev, vcov={"CRV1": "uid"}) | ||||
| 101 | + es = pd.DataFrame({"term": fit.coef().index, "coef": fit.coef().values, "se": fit.se().values}); es.to_csv(P / "modelB_event_study.csv", index=False) | ||||
| 102 | + pre = [t for t in fit.coef().index if any(t.endswith(f"[T.{k}]") for k in (-4, -3, -2))] | ||||
| 103 | + try: | ||||
| 104 | + w = fit.wald_test(R=None, q=None) if False else None | ||||
| 105 | + except Exception: w = None | ||||
| 106 | + b = fit.coef()[pre].values; V = fit._vcov; idx = [list(fit.coef().index).index(t) for t in pre]; Vs = V[np.ix_(idx, idx)] | ||||
| 107 | + W = float(b @ np.linalg.solve(Vs, b)); pval = float(1 - stats.chi2.cdf(W, len(pre))) | ||||
| 108 | + out["pretrend_wald"] = W; out["pretrend_p"] = pval; out["event_study_n"] = int(fit._N) | ||||
| 109 | + fitr = pf.feols("log_ref ~ i(k_f, ref=-1) | uid + week", data=ev, vcov={"CRV1": "uid"}) | ||||
| 110 | + pd.DataFrame({"term": fitr.coef().index, "coef": fitr.coef().values, "se": fitr.se().values}).to_csv(P / "modelB_event_study_ref.csv", index=False) | ||||
| 111 | + # Placebo: random pseudo-episode ends for non-promoted listings | ||||
| 112 | + rng = np.random.default_rng(SEED); pl = [] | ||||
| 113 | + nonp = sf[~sf.promo].groupby("uid").filter(lambda g: len(g) >= 9) | ||||
| 114 | + for uid, g in list(nonp.groupby("uid"))[:3000]: | ||||
| 115 | + g = g.sort_values("week"); i = rng.integers(2, len(g) - 2); pre = g.ref.iloc[i - 1]; post = g.ref.iloc[i + 1] | ||||
| 116 | + pl.append(np.log(post / pre)) | ||||
| 117 | + pl = np.array(pl); out["placebo_dlog_ref_mean"] = float(pl.mean()); out["placebo_dlog_ref_se"] = float(pl.std() / np.sqrt(len(pl))); out["placebo_n"] = int(len(pl)) | ||||
| 118 | + # Circular channel: observed regular prices | ||||
| 119 | + circ = pd.read_parquet(P / "products.parquet"); ret = pd.read_parquet(P / "retailers.parquet") | ||||
| 120 | + circ = circ.merge(ret[["feed_id", "channel", "banner", "is_grocery"]], left_on="source", right_on="feed_id") | ||||
| 121 | + circ = circ[circ.channel.str.startswith("circular") & circ.is_grocery & circ.price.notna()] | ||||
| 122 | + circ["depth_obs"] = np.log(circ.regular_price / circ.price) | ||||
| 123 | + out["circular_items"] = int(len(circ)); out["circular_share_with_regular"] = float(circ.regular_price.notna().mean()) | ||||
| 124 | + out["circular_depth_median"] = float(circ.depth_obs.dropna().median()); out["circular_depth_mean"] = float(circ.depth_obs.dropna().mean()) | ||||
| 125 | + out["circular_share_recurring_items"] = float((circ.span_days > 8).mean()) | ||||
| 126 | + circ.groupby("banner").agg(items=("uid", "size"), with_regular=("regular_price", lambda s: s.notna().mean()), depth_median=("depth_obs", "median"), recurring=("span_days", lambda s: (s > 8).mean())).reset_index().to_csv(P / "modelB_circulars_by_banner.csv", index=False) | ||||
| 127 | + (E / "modelB_summary.json").write_text(json.dumps(out, indent=2, default=float)) | ||||
| 128 | + log.info("H2: sd raw %.4f vs ref %.4f (ratio %.2f, CI %s); promo share %.3f depth med %.3f; agreement w/ on_sale %.2f", out["sd_raw_all"], out["sd_ref_all"], out["ratio_all"], np.round(out["ratio_ci95"], 2), out["promo_share_listing_weeks"], out["promo_depth_median"], agree) | ||||
| 129 | + log.info("H3: episodes %d (uncensored %d), duration median %.1f, dlog_ref mean %.4f (se %.4f) up %.2f down %.2f same %.2f; placebo %.4f; pretrend p %.3f", | ||||
| 130 | + out["n_episodes"], out["n_uncensored"], out["duration_median_unc"], out["H3_dlog_ref_mean"], out["H3_dlog_ref_se"], out["H3_share_ref_up"], out["H3_share_ref_down"], out["H3_share_ref_same"], out["placebo_dlog_ref_mean"], out.get("pretrend_p", float("nan"))) | ||||
| 131 | + ledger("4", "model_b", f"ratio {out['ratio_all']:.2f}; episodes {out['n_episodes']}; H3 mean {out['H3_dlog_ref_mean']:.4f}") | ||||
| 132 | + | ||||
| 133 | +if __name__ == "__main__": | ||||
| 134 | + main() | ||||
added
src/econometrics/model_c_spillovers.py
+63 −0
@@ -0,0 +1,63 @@ | |||||
| 1 | +"""Model C — competitive spillovers (H4), descriptive lead–lag only. Preregistration §2 H4. | ||||
| 2 | + | ||||
| 3 | +Within matched groups (A+B, storefront), regress a banner's weekly log-price change on rivals' current and lagged | ||||
| 4 | +changes (k = 0..3), with group-week and banner fixed effects; leads (k = -1, -2) as placebo. Rival change = mean change of | ||||
| 5 | +the other banners in the group that week. Same-company vs cross-company rivals estimated separately. No causal claim: | ||||
| 6 | +contemporaneous terms absorb common shocks; lagged terms are lead–lag associations. | ||||
| 7 | +""" | ||||
| 8 | +import json, numpy as np, pandas as pd, pyfixest as pf | ||||
| 9 | +from src.common import path, ROOT, get_logger, ledger, seed_everything | ||||
| 10 | +log = get_logger("modelC"); P = path("processed"); E = ROOT / "experiments" | ||||
| 11 | + | ||||
| 12 | +def 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.days | ||||
| 18 | + pm.loc[pm.gap_w != 7, "dlp"] = np.nan | ||||
| 19 | + # rival mean change, same-company vs cross-company | ||||
| 20 | + 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)); continue | ||||
| 41 | + 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-1 | ||||
| 46 | + d["rival_changed_l1"] = d[f"{col}_l1"].abs() > 1e-9; d["own_changed"] = d.dlp.abs() > 1e-9 | ||||
| 47 | + 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.ref | ||||
| 53 | + 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)") | ||||
| 61 | + | ||||
| 62 | +if __name__ == "__main__": | ||||
| 63 | + main() | ||||
added
src/forecasting/__init__.py
+0 −0
added
src/forecasting/rolling_forecast.py
+65 −0
@@ -0,0 +1,65 @@ | |||||
| 1 | +"""Model F — rolling-origin forecasts of next-week posted log price and promotion indicator (preregistration §2 F). | ||||
| 2 | + | ||||
| 3 | +Features use only information up to week t (strict lags). Origins t = 6..10 (train weeks <= t, test week t+1). | ||||
| 4 | +Models: seasonal naive (last value), banner×category mean reversion (shrink toward 8-week listing mode), ridge, LightGBM. | ||||
| 5 | +Promotion target: next week p <= 0.9 x trailing 8-week mode. Metrics: MAE/RMSE (log price), Brier/AUC (promotion). | ||||
| 6 | +""" | ||||
| 7 | +import json, numpy as np, pandas as pd, lightgbm as lgb | ||||
| 8 | +from sklearn.linear_model import Ridge, LogisticRegression | ||||
| 9 | +from sklearn.metrics import roc_auc_score, brier_score_loss | ||||
| 10 | +from src.common import path, ROOT, get_logger, ledger, seed_everything, SEED | ||||
| 11 | +log = get_logger("modelF"); P = path("processed"); E = ROOT / "experiments" | ||||
| 12 | + | ||||
| 13 | +def rolling_mode_series(s, window=8): | ||||
| 14 | + out = [] | ||||
| 15 | + v = s.tolist() | ||||
| 16 | + for i in range(len(v)): | ||||
| 17 | + w = pd.Series(v[max(0, i - window + 1): i + 1]).value_counts(); out.append(max(w[w == w.max()].index)) | ||||
| 18 | + return pd.Series(out, index=s.index) | ||||
| 19 | + | ||||
| 20 | +def main(): | ||||
| 21 | + seed_everything(); out = {} | ||||
| 22 | + pw = pd.read_parquet(P / "panel_weekly.parquet"); sf = pw[pw.channel == "online_storefront"].sort_values(["uid", "week"]).copy() | ||||
| 23 | + sf = sf[sf.groupby("uid").week.transform("nunique") >= 6] | ||||
| 24 | + sf["t"] = sf.week.rank(method="dense").astype(int) | ||||
| 25 | + g = sf.groupby("uid") | ||||
| 26 | + sf["ref"] = g.p_mode.transform(rolling_mode_series); sf["promo"] = (sf.p_mode <= 0.9 * sf.ref).astype(int) | ||||
| 27 | + for k in (1, 2, 3): sf[f"lp_l{k}"] = g.log_p.shift(k) | ||||
| 28 | + sf["lp_ref"] = np.log(sf.ref); sf["promo_l1"] = g.promo.shift(1); sf["promo_l2"] = g.promo.shift(2) | ||||
| 29 | + sf["n_changes_l0"] = sf.n_changes; sf["rel_range_l0"] = sf.rel_range; sf["multi_l0"] = sf.multi_price_week.astype(int) | ||||
| 30 | + sf["promo_rate_hist"] = g.promo.transform(lambda s: s.shift(1).expanding().mean()) | ||||
| 31 | + sf["y_lp"] = g.log_p.shift(-1); sf["y_promo"] = g.promo.shift(-1); sf["t_next"] = g.t.shift(-1) | ||||
| 32 | + sf = sf[(sf.t_next == sf.t + 1)] | ||||
| 33 | + sf["banner_c"] = sf.banner.astype("category").cat.codes; sf["cat_c"] = sf.category.astype("category").cat.codes | ||||
| 34 | + feats = ["log_p", "lp_l1", "lp_l2", "lp_l3", "lp_ref", "promo", "promo_l1", "promo_l2", "n_changes_l0", "rel_range_l0", "multi_l0", "promo_rate_hist", "banner_c", "cat_c", "t"] | ||||
| 35 | + res = [] | ||||
| 36 | + for t in range(6, int(sf.t.max())): | ||||
| 37 | + tr = sf[(sf.t <= t - 1)].dropna(subset=feats + ["y_lp", "y_promo"]); te = sf[sf.t == t].dropna(subset=feats + ["y_lp", "y_promo"]) | ||||
| 38 | + if len(te) == 0 or len(tr) == 0: continue | ||||
| 39 | + # price models | ||||
| 40 | + preds = {"naive_last": te.log_p.values, "mean_reversion_ref": 0.5 * te.log_p.values + 0.5 * te.lp_ref.values} | ||||
| 41 | + bc = tr.groupby(["banner", "category"]).apply(lambda d: (d.y_lp - d.log_p).mean()).rename("drift").reset_index() | ||||
| 42 | + preds["banner_cat_drift"] = te.log_p.values + te.merge(bc, on=["banner", "category"], how="left").drift.fillna(0).values | ||||
| 43 | + X = tr[feats].values; Xt = te[feats].values | ||||
| 44 | + rd = Ridge(alpha=1.0).fit(X, tr.y_lp.values); preds["ridge"] = rd.predict(Xt) | ||||
| 45 | + gb = lgb.LGBMRegressor(n_estimators=400, learning_rate=0.05, num_leaves=63, min_child_samples=50, subsample=0.8, colsample_bytree=0.8, random_state=SEED, verbose=-1).fit(X, tr.y_lp.values) | ||||
| 46 | + preds["lightgbm"] = gb.predict(Xt) | ||||
| 47 | + for m, p in preds.items(): | ||||
| 48 | + e = p - te.y_lp.values; res.append(dict(origin_t=t, target="log_price", model=m, n=len(te), mae=float(np.mean(np.abs(e))), rmse=float(np.sqrt(np.mean(e ** 2))))) | ||||
| 49 | + # promotion models | ||||
| 50 | + pp = {"persist": te.promo.values.astype(float), "base_rate": np.full(len(te), tr.y_promo.mean()), "hist_rate": te.promo_rate_hist.fillna(tr.y_promo.mean()).values} | ||||
| 51 | + lr = LogisticRegression(max_iter=2000).fit(X, tr.y_promo.values); pp["logit"] = lr.predict_proba(Xt)[:, 1] | ||||
| 52 | + gc = lgb.LGBMClassifier(n_estimators=400, learning_rate=0.05, num_leaves=63, min_child_samples=50, subsample=0.8, colsample_bytree=0.8, random_state=SEED, verbose=-1).fit(X, tr.y_promo.values) | ||||
| 53 | + pp["lightgbm"] = gc.predict_proba(Xt)[:, 1] | ||||
| 54 | + for m, p in pp.items(): | ||||
| 55 | + y = te.y_promo.values | ||||
| 56 | + res.append(dict(origin_t=t, target="promotion", model=m, n=len(te), brier=float(brier_score_loss(y, np.clip(p, 0, 1))), auc=float(roc_auc_score(y, p)) if len(set(y)) > 1 else np.nan, base_rate=float(y.mean()))) | ||||
| 57 | + r = pd.DataFrame(res); r.to_csv(P / "modelF_rolling_results.csv", index=False) | ||||
| 58 | + summ = r.groupby(["target", "model"]).agg(origins=("origin_t", "nunique"), n=("n", "sum"), mae=("mae", "mean"), rmse=("rmse", "mean"), brier=("brier", "mean"), auc=("auc", "mean")).reset_index() | ||||
| 59 | + summ.to_csv(P / "modelF_summary.csv", index=False); out["summary"] = summ.to_dict(orient="records") | ||||
| 60 | + (E / "modelF_summary.json").write_text(json.dumps(out, indent=2, default=float)) | ||||
| 61 | + log.info("\n%s", summ.round(4).to_string(index=False)) | ||||
| 62 | + ledger("4", "model_f", "rolling-origin forecasts done") | ||||
| 63 | + | ||||
| 64 | +if __name__ == "__main__": | ||||
| 65 | + main() | ||||
added
src/indices/__init__.py
+0 −0
added
src/indices/basket_optimization.py
+83 −0
@@ -0,0 +1,83 @@ | |||||
| 1 | +"""Model E — fixed-basket shopping optimisation (H6). Preregistration §2 H6 and config/basket.yaml (frozen). | ||||
| 2 | + | ||||
| 3 | +For each complete week: map each basket item to candidate listings per banner (category + name regex, parsable size, | ||||
| 4 | +unit price), take the cheapest unit price per banner, and solve min-cost assignment with at most K banners (K=1..5) as a | ||||
| 5 | +MIP (PuLP/CBC). Travel costs are unobserved: an illustrative fixed per-banner cost grid is added as a labelled assumption. | ||||
| 6 | +""" | ||||
| 7 | +import json, re, numpy as np, pandas as pd, pulp, yaml | ||||
| 8 | +from src.common import path, ROOT, get_logger, ledger | ||||
| 9 | +log = get_logger("modelE"); P = path("processed"); E = ROOT / "experiments" | ||||
| 10 | + | ||||
| 11 | +def candidates(pw, items): | ||||
| 12 | + rows = [] | ||||
| 13 | + for it in items: | ||||
| 14 | + d = pw[(pw.category == it["category"]) & pw.name.fillna("").str.contains(it["name_regex"], case=False, regex=True)] | ||||
| 15 | + if it.get("exclude_regex"): d = d[~d.name.fillna("").str.contains(it["exclude_regex"], case=False, regex=True)] | ||||
| 16 | + d = d[(d.unit_base == ("un" if it["base"] == "un" else f"100 {it['base']}")) & d.unit_p.notna() & (d.unit_p > 0)] | ||||
| 17 | + if len(d) == 0: continue | ||||
| 18 | + d = d.assign(item=it["id"], item_cost=lambda x: x.unit_p * (it["qty"] if it["base"] == "un" else it["qty"] / 100.0)) | ||||
| 19 | + rows.append(d[["item", "week", "banner", "company", "uid", "name", "size_label", "p_mode", "unit_p", "item_cost"]]) | ||||
| 20 | + return pd.concat(rows, ignore_index=True) | ||||
| 21 | + | ||||
| 22 | +def solve(cost_tbl, K, fixed_cost=0.0): | ||||
| 23 | + """cost_tbl: item x banner matrix of min item cost (NaN = unavailable). Returns (total cost, banners used, items covered).""" | ||||
| 24 | + items = list(cost_tbl.index); banners = list(cost_tbl.columns) | ||||
| 25 | + prob = pulp.LpProblem("basket", pulp.LpMinimize) | ||||
| 26 | + x = {(i, b): pulp.LpVariable(f"x_{n}_{m}", cat="Binary") for n, i in enumerate(items) for m, b in enumerate(banners) if not np.isnan(cost_tbl.at[i, b])} | ||||
| 27 | + y = {b: pulp.LpVariable(f"y_{m}", cat="Binary") for m, b in enumerate(banners)} | ||||
| 28 | + prob += pulp.lpSum(cost_tbl.at[i, b] * v for (i, b), v in x.items()) + fixed_cost * pulp.lpSum(y.values()) | ||||
| 29 | + for i in items: | ||||
| 30 | + prob += pulp.lpSum(v for (ii, b), v in x.items() if ii == i) == 1 | ||||
| 31 | + for (i, b), v in x.items(): | ||||
| 32 | + prob += v <= y[b] | ||||
| 33 | + prob += pulp.lpSum(y.values()) <= K | ||||
| 34 | + prob.solve(pulp.PULP_CBC_CMD(msg=False)) | ||||
| 35 | + if pulp.LpStatus[prob.status] != "Optimal": return np.nan, [], 0 | ||||
| 36 | + used = [b for b, v in y.items() if v.value() > 0.5] | ||||
| 37 | + tot = sum(cost_tbl.at[i, b] * v.value() for (i, b), v in x.items()) | ||||
| 38 | + return float(tot), used, len(items) | ||||
| 39 | + | ||||
| 40 | +def main(): | ||||
| 41 | + out = {} | ||||
| 42 | + items = yaml.safe_load((ROOT / "config" / "basket.yaml").read_text())["items"] | ||||
| 43 | + pw = pd.read_parquet(P / "panel_weekly.parquet"); sf = pw[pw.channel == "online_storefront"] | ||||
| 44 | + cand = candidates(sf, items); cand.to_parquet(P / "modelE_candidates.parquet", index=False) | ||||
| 45 | + cov = cand.groupby("item").agg(listings=("uid", "nunique"), banners=("banner", "nunique")).reset_index(); cov.to_csv(P / "modelE_item_coverage.csv", index=False) | ||||
| 46 | + log.info("basket items with candidates: %d / %d ; median banners per item %.0f", cov.item.nunique(), len(items), cov.banners.median()) | ||||
| 47 | + results = [] | ||||
| 48 | + for w, d in cand.groupby("week"): | ||||
| 49 | + tbl = d.groupby(["item", "banner"]).item_cost.min().unstack() | ||||
| 50 | + # keep banners carrying >= 60 % of covered items, and items available at >= 2 banners (so that K=1 is feasible on a common set) | ||||
| 51 | + tbl = tbl.loc[:, tbl.notna().mean() >= 0.6] | ||||
| 52 | + tbl = tbl[tbl.notna().sum(axis=1) >= 2] | ||||
| 53 | + # for K=1 feasibility, restrict to items available at every retained banner | ||||
| 54 | + full = tbl.dropna() | ||||
| 55 | + for K in (1, 2, 3, 4, 5, len(tbl.columns)): | ||||
| 56 | + tot, used, n = solve(full, K) | ||||
| 57 | + results.append(dict(week=w, K=K if K < len(tbl.columns) else 99, cost=tot, banners=";".join(used), n_items=n, n_banners_avail=len(tbl.columns))) | ||||
| 58 | + # single-banner cost for each banner (full item set) | ||||
| 59 | + for b in full.columns: | ||||
| 60 | + results.append(dict(week=w, K=1, cost=float(full[b].sum()), banners=b, n_items=len(full), n_banners_avail=len(tbl.columns), single=True)) | ||||
| 61 | + for fc in (5.0, 10.0, 20.0): | ||||
| 62 | + tot, used, n = solve(full, len(full.columns), fixed_cost=fc) | ||||
| 63 | + results.append(dict(week=w, K=99, cost=tot - fc * len(used), banners=";".join(used), n_items=n, n_banners_avail=len(tbl.columns), fixed_cost=fc, n_used=len(used), cost_incl_travel=tot)) | ||||
| 64 | + r = pd.DataFrame(results); r.to_csv(P / "modelE_results.csv", index=False) | ||||
| 65 | + base = r[(r.get("single").isna()) & (r.fixed_cost.isna())] if "fixed_cost" in r else r[r.get("single").isna()] | ||||
| 66 | + front = base.groupby("K").cost.mean(); singles = r[r.get("single") == True].groupby("week").cost.agg(["min", "max", "mean"]) | ||||
| 67 | + out["items_in_common_set_mean"] = float(base.n_items.mean()); out["banners_available_mean"] = float(base.n_banners_avail.mean()) | ||||
| 68 | + out["frontier_mean_cost_by_K"] = {int(k): float(v) for k, v in front.items()} | ||||
| 69 | + c1, c2, cinf = front.get(1), front.get(2), front.get(99) | ||||
| 70 | + out["savings_unconstrained_vs_best_single"] = float(c1 - cinf); out["savings_share_unconstrained"] = float((c1 - cinf) / c1) | ||||
| 71 | + out["share_of_unconstrained_savings_at_K2"] = float((c1 - c2) / (c1 - cinf)) if c1 - cinf > 0 else None | ||||
| 72 | + out["single_banner_cost_spread_mean"] = float((singles["max"] - singles["min"]).mean()); out["single_banner_mean_cost"] = float(singles["mean"].mean()) | ||||
| 73 | + out["most_expensive_vs_cheapest_single_share"] = float(((singles["max"] - singles["min"]) / singles["min"]).mean()) | ||||
| 74 | + if "fixed_cost" in r: | ||||
| 75 | + tc = r[r.fixed_cost.notna()].groupby("fixed_cost").agg(n_used=("n_used", "mean"), cost_goods=("cost", "mean"), cost_incl=("cost_incl_travel", "mean")).reset_index() | ||||
| 76 | + tc.to_csv(P / "modelE_travel_sensitivity.csv", index=False); out["travel_cost_sensitivity"] = tc.to_dict(orient="records") | ||||
| 77 | + (E / "modelE_summary.json").write_text(json.dumps(out, indent=2, default=float)) | ||||
| 78 | + log.info("E: common items %.1f, banners %.1f ; cost K=1 %.2f, K=2 %.2f, unconstrained %.2f ; unconstrained savings %.1f%% ; share captured at K=2 %.2f ; single-banner spread %.2f (%.1f%%)", | ||||
| 79 | + out["items_in_common_set_mean"], out["banners_available_mean"], c1, c2, cinf, 100 * out["savings_share_unconstrained"], out["share_of_unconstrained_savings_at_K2"] or float("nan"), out["single_banner_cost_spread_mean"], 100 * out["most_expensive_vs_cheapest_single_share"]) | ||||
| 80 | + ledger("4", "model_e", f"K1 {c1:.2f} K2 {c2:.2f} Kinf {cinf:.2f}") | ||||
| 81 | + | ||||
| 82 | +if __name__ == "__main__": | ||||
| 83 | + main() | ||||
added
src/indices/price_indices.py
+86 −0
@@ -0,0 +1,86 @@ | |||||
| 1 | +"""Model D — price indices (H5). Preregistration §2 H5. No expenditure weights exist: all indices are unweighted and are | ||||
| 2 | +NOT consumer price indices. Size-label history is not observed (only current state) → within-listing package changes are | ||||
| 3 | +unobservable; this is reported as a limitation, not estimated. | ||||
| 4 | +""" | ||||
| 5 | +import json, numpy as np, pandas as pd | ||||
| 6 | +from src.common import path, ROOT, get_logger, ledger, seed_everything, SEED | ||||
| 7 | +log = get_logger("modelD"); P = path("processed"); E = ROOT / "experiments" | ||||
| 8 | + | ||||
| 9 | +def jevons_chained(df, col): | ||||
| 10 | + """Chained Jevons (matched-model): geometric mean of week-on-week ratios over listings present in both weeks.""" | ||||
| 11 | + weeks = sorted(df.week.unique()); idx = [100.0]; n = [np.nan] | ||||
| 12 | + piv = df.pivot_table(index="uid", columns="week", values=col) | ||||
| 13 | + for a, b in zip(weeks, weeks[1:]): | ||||
| 14 | + both = piv[[a, b]].dropna(); r = np.exp((np.log(both[b]) - np.log(both[a])).mean()) if len(both) else np.nan | ||||
| 15 | + idx.append(idx[-1] * r); n.append(len(both)) | ||||
| 16 | + return pd.DataFrame({"week": weeks, "index": idx, "n_matched": n}) | ||||
| 17 | + | ||||
| 18 | +def fixed_basket(df, col): | ||||
| 19 | + """Balanced listings (present in every week), equal weights: Dutot (ratio of means), Carli (mean of ratios), Jevons (geo mean of ratios).""" | ||||
| 20 | + weeks = sorted(df.week.unique()); piv = df.pivot_table(index="uid", columns="week", values=col).dropna() | ||||
| 21 | + base = piv[weeks[0]] | ||||
| 22 | + rows = [] | ||||
| 23 | + for w in weeks: | ||||
| 24 | + rows.append(dict(week=w, n=len(piv), dutot=100 * piv[w].mean() / base.mean(), carli=100 * (piv[w] / base).mean(), jevons=100 * np.exp(np.log(piv[w] / base).mean()))) | ||||
| 25 | + return pd.DataFrame(rows) | ||||
| 26 | + | ||||
| 27 | +def naive_mean(df, col): | ||||
| 28 | + g = df.groupby("week")[col].agg(["mean", "median", "size"]).reset_index(); g["index_mean"] = 100 * g["mean"] / g["mean"].iloc[0]; g["index_median"] = 100 * g["median"] / g["median"].iloc[0] | ||||
| 29 | + return g | ||||
| 30 | + | ||||
| 31 | +def main(): | ||||
| 32 | + seed_everything(); out = {} | ||||
| 33 | + pw = pd.read_parquet(P / "panel_weekly.parquet"); sf = pw[pw.channel == "online_storefront"].copy() | ||||
| 34 | + res = {} | ||||
| 35 | + for name, d, col in [("storefront_posted", sf, "p_mode"), ("storefront_unit", sf[sf.unit_p.notna()], "unit_p"), ("all_channels_posted", pw, "p_mode")]: | ||||
| 36 | + nm = naive_mean(d, col); jc = jevons_chained(d, col); fb = fixed_basket(d, col) | ||||
| 37 | + m = nm.merge(jc, on="week").merge(fb, on="week", suffixes=("", "_fb")); m["sample"] = name; res[name] = m | ||||
| 38 | + out[name] = dict(naive_mean_change=float(nm.index_mean.iloc[-1] / 100 - 1), naive_median_change=float(nm.index_median.iloc[-1] / 100 - 1), jevons_chained_change=float(jc["index"].iloc[-1] / 100 - 1), | ||||
| 39 | + fixed_jevons_change=float(fb.jevons.iloc[-1] / 100 - 1), fixed_dutot_change=float(fb.dutot.iloc[-1] / 100 - 1), fixed_carli_change=float(fb.carli.iloc[-1] / 100 - 1), n_balanced=int(fb.n.iloc[0]), n_total_first_week=int(nm["size"].iloc[0])) | ||||
| 40 | + pd.concat(res.values()).to_csv(P / "modelD_indices.csv", index=False) | ||||
| 41 | + # by category (storefront posted) | ||||
| 42 | + bc = [] | ||||
| 43 | + for c, d in sf.groupby("category"): | ||||
| 44 | + if d.uid.nunique() < 100: continue | ||||
| 45 | + nm = naive_mean(d, "p_mode"); jc = jevons_chained(d, "p_mode"); fb = fixed_basket(d, "p_mode") | ||||
| 46 | + bc.append(dict(category=c, listings=d.uid.nunique(), naive_mean_change=nm.index_mean.iloc[-1] / 100 - 1, jevons_chained_change=jc["index"].iloc[-1] / 100 - 1, fixed_jevons_change=fb.jevons.iloc[-1] / 100 - 1, n_balanced=fb.n.iloc[0])) | ||||
| 47 | + pd.DataFrame(bc).to_csv(P / "modelD_indices_by_category.csv", index=False) | ||||
| 48 | + # by banner (storefront posted): Jevons chained | ||||
| 49 | + bb = [] | ||||
| 50 | + for b, d in sf.groupby("banner"): | ||||
| 51 | + if d.uid.nunique() < 100: continue | ||||
| 52 | + jc = jevons_chained(d, "p_mode"); fb = fixed_basket(d, "p_mode"); nm = naive_mean(d, "p_mode") | ||||
| 53 | + bb.append(dict(banner=b, listings=d.uid.nunique(), jevons_chained_change=jc["index"].iloc[-1] / 100 - 1, fixed_jevons_change=fb.jevons.iloc[-1] / 100 - 1, naive_mean_change=nm.index_mean.iloc[-1] / 100 - 1, n_balanced=fb.n.iloc[0])) | ||||
| 54 | + pd.DataFrame(bb).to_csv(P / "modelD_indices_by_banner.csv", index=False) | ||||
| 55 | + # Bootstrap (listing-level, B=999) of the naive-vs-Jevons gap, storefront posted | ||||
| 56 | + rng = np.random.default_rng(SEED); uids = sf.uid.unique(); piv = sf.pivot_table(index="uid", columns="week", values="p_mode"); weeks = list(piv.columns) | ||||
| 57 | + gaps = []; jev = [] | ||||
| 58 | + for b in range(999): | ||||
| 59 | + s = rng.choice(uids, size=len(uids), replace=True); pb = piv.loc[s] | ||||
| 60 | + naive = pb[weeks[-1]].mean() / pb[weeks[0]].mean() - 1 | ||||
| 61 | + lj = 0.0 | ||||
| 62 | + for a, c in zip(weeks, weeks[1:]): | ||||
| 63 | + both = pb[[a, c]].dropna(); lj += (np.log(both[c]) - np.log(both[a])).mean() | ||||
| 64 | + jev.append(np.exp(lj) - 1); gaps.append(naive - (np.exp(lj) - 1)) | ||||
| 65 | + out["bootstrap"] = dict(gap_mean=float(np.mean(gaps)), gap_ci95=[float(np.percentile(gaps, 2.5)), float(np.percentile(gaps, 97.5))], jevons_ci95=[float(np.percentile(jev, 2.5)), float(np.percentile(jev, 97.5))]) | ||||
| 66 | + # Assortment turnover (entry/exit) — the observable part of H5 | ||||
| 67 | + first = sf.groupby("uid").week.min(); last = sf.groupby("uid").week.max(); weeks_s = sorted(sf.week.unique()) | ||||
| 68 | + out["assortment"] = dict(listings=int(sf.uid.nunique()), balanced=int(((first == weeks_s[0]) & (last == weeks_s[-1])).sum()), entrants=int((first > weeks_s[0]).sum()), exits=int((last < weeks_s[-1]).sum()), | ||||
| 69 | + weekly_entry_rate=float(sf.groupby("week").uid.nunique().pipe(lambda s: (first.value_counts().reindex(weeks_s).fillna(0)[1:] / s.values[:-1]).mean()))) | ||||
| 70 | + # Unit-price vs posted: cross-banner unit price gap for matched groups is covered in Model A; here, size-missing share | ||||
| 71 | + out["size_label_history_available"] = False | ||||
| 72 | + # StatCan benchmark: Quebec CPI 'Food purchased from stores' Aug->Sep 2026 ; our storefront Jevons Aug (weeks 13,20,27 Aug) -> Sep (3,10,17,24 Sep) | ||||
| 73 | + cpi = pd.read_csv(P / "statcan_cpi_food.csv"); q = cpi[(cpi.geo == "Quebec") & (cpi["product"] == "Food purchased from stores")].sort_values("ref_date") | ||||
| 74 | + aug = q[q.ref_date.str.startswith("2026-08")].value.iloc[0]; sep = q[q.ref_date.str.startswith("2026-09")].value.iloc[0] | ||||
| 75 | + jc = jevons_chained(sf, "p_mode"); jc["month"] = pd.to_datetime(jc.week).dt.month | ||||
| 76 | + ours = jc.groupby("month")["index"].mean(); out["statcan"] = dict(cpi_qc_food_stores_aug=float(aug), sep=float(sep), cpi_change_aug_sep=float(sep / aug - 1), ours_jevons_aug_sep=float(ours.loc[9] / ours.loc[8] - 1), | ||||
| 77 | + cpi_latest=str(q.ref_date.iloc[-1]), cpi_yoy_sep=float(sep / q[q.ref_date.str.startswith("2025-09")].value.iloc[0] - 1)) | ||||
| 78 | + (E / "modelD_summary.json").write_text(json.dumps(out, indent=2, default=float)) | ||||
| 79 | + s = out["storefront_posted"] | ||||
| 80 | + log.info("D storefront: naive mean %.3f, naive median %.3f, Jevons chained %.3f (CI %s), fixed Jevons %.3f (n=%d), Dutot %.3f, Carli %.3f; gap naive−Jevons %.3f CI %s", | ||||
| 81 | + s["naive_mean_change"], s["naive_median_change"], s["jevons_chained_change"], np.round(out["bootstrap"]["jevons_ci95"], 3), s["fixed_jevons_change"], s["n_balanced"], s["fixed_dutot_change"], s["fixed_carli_change"], out["bootstrap"]["gap_mean"], np.round(out["bootstrap"]["gap_ci95"], 3)) | ||||
| 82 | + log.info("StatCan QC food-from-stores Aug→Sep %.3f vs ours %.3f ; CPI yoy Sep %.3f", out["statcan"]["cpi_change_aug_sep"], out["statcan"]["ours_jevons_aug_sep"], out["statcan"]["cpi_yoy_sep"]) | ||||
| 83 | + ledger("4", "model_d", f"Jevons {s['jevons_chained_change']:.3f} vs naive {s['naive_mean_change']:.3f}") | ||||
| 84 | + | ||||
| 85 | +if __name__ == "__main__": | ||||
| 86 | + main() | ||||
| 87 | |||||