phase4-7: model B episode classification + event study by feed type, robustness, QGPAI, tables and figures
35 changed files +1,303 −60
added
experiments/modelB_summary.json
+74 −0
@@ -0,0 +1,74 @@ | |||||
| 1 | +{ | ||||
| 2 | + "promo_vs_on_sale_now_agreement": 0.9022263739685505, | ||||
| 3 | + "promo_rate_last_week": 0.043515491203487466, | ||||
| 4 | + "on_sale_now_rate": 0.09520473299081426, | ||||
| 5 | + "promo_share_listing_weeks": 0.04568753591303905, | ||||
| 6 | + "promo_depth_median": 0.2236446788616552, | ||||
| 7 | + "promo_depth_mean": 0.29059964875635586, | ||||
| 8 | + "sd_raw_all": 0.10565149941332518, | ||||
| 9 | + "sd_ref_all": 0.0549710485176463, | ||||
| 10 | + "ratio_all": 1.921948048333295, | ||||
| 11 | + "share_weekly_changes_promo_transition": 0.5035401059747853, | ||||
| 12 | + "freq_weekly_change": 0.0848731090417077, | ||||
| 13 | + "ratio_ci95": [ | ||||
| 14 | + 1.836695161638567, | ||||
| 15 | + 2.0213660408214396 | ||||
| 16 | + ], | ||||
| 17 | + "multi_price_week_share_single_store": 0.047121258277083904, | ||||
| 18 | + "multi_price_week_share_antibot": 0.22767465372042062, | ||||
| 19 | + "P_stay_promo": 0.4895556233919149, | ||||
| 20 | + "P_enter_promo": 0.029970889247279107, | ||||
| 21 | + "n_episodes": 14774, | ||||
| 22 | + "n_uncensored": 11705, | ||||
| 23 | + "duration_median_unc": 1.0, | ||||
| 24 | + "duration_mean_unc": 1.6776591200341735, | ||||
| 25 | + "share_duration_1w": 0.6444254592054678, | ||||
| 26 | + "depth_median_unc": 0.2469476091145657, | ||||
| 27 | + "recurrence_median_weeks": 3.0, | ||||
| 28 | + "share_listings_with_2plus_episodes": 0.2286878848321915, | ||||
| 29 | + "episode_kind_shares": { | ||||
| 30 | + "return": 0.5168, | ||||
| 31 | + "reset_down": 0.4192, | ||||
| 32 | + "reset_up": 0.064 | ||||
| 33 | + }, | ||||
| 34 | + "episode_kind_counts": { | ||||
| 35 | + "return": 6049, | ||||
| 36 | + "reset_down": 4907, | ||||
| 37 | + "reset_up": 749 | ||||
| 38 | + }, | ||||
| 39 | + "return_episodes": { | ||||
| 40 | + "n": 6049, | ||||
| 41 | + "duration_median": 1.0, | ||||
| 42 | + "duration_mean": 1.291949082492974, | ||||
| 43 | + "depth_median": 0.2878488363978023, | ||||
| 44 | + "share_duration_1w": 0.8022813688212928 | ||||
| 45 | + }, | ||||
| 46 | + "H3_return_dlog_ref_mean": -0.00027505967770971286, | ||||
| 47 | + "H3_return_dlog_ref_se": 0.00010549923745584916, | ||||
| 48 | + "H3_return_n": 6049, | ||||
| 49 | + "H3_return_share_up": 0.004628864275086791, | ||||
| 50 | + "H3_return_share_down": 0.005124814018846091, | ||||
| 51 | + "H3_dlog_ref_mean": -0.1017833150707028, | ||||
| 52 | + "H3_dlog_ref_se": 0.0017635903015457343, | ||||
| 53 | + "H3_n": 11705, | ||||
| 54 | + "H3_share_ref_up": 0.019564288765484834, | ||||
| 55 | + "H3_share_ref_down": 0.39982913284920973, | ||||
| 56 | + "H3_share_ref_same": 0.5806065783853054, | ||||
| 57 | + "H3_post_price_vs_pre_ref_mean": -0.09291344434204576, | ||||
| 58 | + "H3_post_price_vs_pre_ref_share_up": 0.07082443400256301, | ||||
| 59 | + "pretrend_wald": 695.3931784272776, | ||||
| 60 | + "pretrend_p": 0.0, | ||||
| 61 | + "event_study_n": 46842, | ||||
| 62 | + "event_study_by_feedtype_n": { | ||||
| 63 | + "antibot": 28640, | ||||
| 64 | + "single_store": 18202 | ||||
| 65 | + }, | ||||
| 66 | + "placebo_dlog_ref_mean": 0.000333583546264452, | ||||
| 67 | + "placebo_dlog_ref_se": 0.0006713196779154989, | ||||
| 68 | + "placebo_n": 3000, | ||||
| 69 | + "circular_items": 33006, | ||||
| 70 | + "circular_share_with_regular": 0.22704962734048356, | ||||
| 71 | + "circular_depth_median": 0.2236446788616552, | ||||
| 72 | + "circular_depth_mean": 0.2873800535908563, | ||||
| 73 | + "circular_share_recurring_items": 0.5973762346240078 | ||||
| 74 | +} | ||||
| \ No newline at end of file | |||||
modified
experiments/modelE_summary.json
+72 −22
@@ -1,38 +1,88 @@ | |||||
| 1 | { | 1 | { | ||
| 2 | − "items_in_common_set_mean": 7.181818181818182, | 2 | + "fixed_block": { | ||
| 3 | − "banners_available_mean": 7.7272727272727275, | 3 | + "banners": [ | ||
| 4 | + "Super C", | ||||
| 5 | + "Metro", | ||||
| 6 | + "Giant Tiger", | ||||
| 7 | + "IGA", | ||||
| 8 | + "Walmart" | ||||
| 9 | + ], | ||||
| 10 | + "n_items": 10, | ||||
| 11 | + "mean_cost_K1": 32.31608185331482, | ||||
| 12 | + "mean_cost_K2": 29.053711571037525, | ||||
| 13 | + "mean_cost_unc": 28.020668381660162 | ||||
| 14 | + }, | ||||
| 15 | + "block_sensitivity": [ | ||||
| 16 | + { | ||||
| 17 | + "B": 4, | ||||
| 18 | + "n_items": 18, | ||||
| 19 | + "cost_K1": 78.72061944703458, | ||||
| 20 | + "cost_K2": 68.96503896142866, | ||||
| 21 | + "cost_unconstrained": 66.86453724461143, | ||||
| 22 | + "savings_share": 0.15060961518983287, | ||||
| 23 | + "share_at_K2": 0.8228334047491731 | ||||
| 24 | + }, | ||||
| 25 | + { | ||||
| 26 | + "B": 5, | ||||
| 27 | + "n_items": 15, | ||||
| 28 | + "cost_K1": 67.28423159055245, | ||||
| 29 | + "cost_K2": 58.50114263955248, | ||||
| 30 | + "cost_unconstrained": 56.91885773846968, | ||||
| 31 | + "savings_share": 0.1540535368696727, | ||||
| 32 | + "share_at_K2": 0.8473489790467271 | ||||
| 33 | + }, | ||||
| 34 | + { | ||||
| 35 | + "B": 6, | ||||
| 36 | + "n_items": 11, | ||||
| 37 | + "cost_K1": 49.27197360107939, | ||||
| 38 | + "cost_K2": 45.0457230043298, | ||||
| 39 | + "cost_unconstrained": 43.6571219953653, | ||||
| 40 | + "savings_share": 0.11395629594977465, | ||||
| 41 | + "share_at_K2": 0.75269141439974 | ||||
| 42 | + }, | ||||
| 43 | + { | ||||
| 44 | + "B": 7, | ||||
| 45 | + "n_items": 9, | ||||
| 46 | + "cost_K1": 31.013118925707076, | ||||
| 47 | + "cost_K2": 28.327593246343564, | ||||
| 48 | + "cost_unconstrained": 27.284743682204486, | ||||
| 49 | + "savings_share": 0.12021929340399568, | ||||
| 50 | + "share_at_K2": 0.7202938288048005 | ||||
| 51 | + } | ||||
| 52 | + ], | ||||
| 53 | + "items_in_common_set_mean": 16.363636363636363, | ||||
| 54 | + "banners_available_mean": 5.0, | ||||
| 4 | "frontier_mean_cost_by_K": { | 55 | "frontier_mean_cost_by_K": { | ||
| 5 | − "1": 23.12194937122785, | 56 | + "1": 67.28423159055245, | ||
| 6 | − "2": 20.888101262563136, | 57 | + "2": 58.50114263955248, | ||
| 7 | − "3": 20.204123130937315, | 58 | + "3": 57.245563977839794, | ||
| 8 | − "4": 20.054139457868775, | 59 | + "4": 56.98363046574241, | ||
| 9 | − "5": 20.034210164939484, | 60 | + "99": 56.91885773846968 | ||
| 10 | − "99": 20.028755619484937 | ||||
| 11 | }, | 61 | }, | ||
| 12 | − "savings_unconstrained_vs_best_single": 3.0931937517429127, | 62 | + "savings_unconstrained_vs_best_single": 10.365373852082769, | ||
| 13 | − "savings_share_unconstrained": 0.1337773775939487, | 63 | + "savings_share_unconstrained": 0.1540535368696727, | ||
| 14 | − "share_of_unconstrained_savings_at_K2": 0.7221817603265276, | 64 | + "share_of_unconstrained_savings_at_K2": 0.8473489790467271, | ||
| 15 | − "single_banner_cost_spread_mean": 39.69805844961497, | 65 | + "single_banner_cost_spread_mean": 60.83196506195359, | ||
| 16 | − "single_banner_mean_cost": 32.88295457993973, | 66 | + "single_banner_mean_cost": 87.58897378738362, | ||
| 17 | − "most_expensive_vs_cheapest_single_share": 1.856238474704022, | 67 | + "most_expensive_vs_cheapest_single_share": 0.9131745576696246, | ||
| 18 | "travel_cost_sensitivity": [ | 68 | "travel_cost_sensitivity": [ | ||
| 19 | { | 69 | { | ||
| 20 | "fixed_cost": 5.0, | 70 | "fixed_cost": 5.0, | ||
| 21 | − "n_used": 1.0, | 71 | + "n_used": 1.8181818181818181, | ||
| 22 | − "cost_goods": 18.12194937122785, | 72 | + "cost_goods": 59.313548870407836, | ||
| 23 | − "cost_incl": 23.12194937122785 | 73 | + "cost_incl": 68.40445796131691 | ||
| 24 | }, | 74 | }, | ||
| 25 | { | 75 | { | ||
| 26 | "fixed_cost": 10.0, | 76 | "fixed_cost": 10.0, | ||
| 27 | − "n_used": 1.0, | 77 | + "n_used": 1.2727272727272727, | ||
| 28 | − "cost_goods": 13.12194937122785, | 78 | + "cost_goods": 63.538701559764334, | ||
| 29 | − "cost_incl": 23.12194937122785 | 79 | + "cost_incl": 76.26597428703707 | ||
| 30 | }, | 80 | }, | ||
| 31 | { | 81 | { | ||
| 32 | "fixed_cost": 20.0, | 82 | "fixed_cost": 20.0, | ||
| 33 | "n_used": 1.0, | 83 | "n_used": 1.0, | ||
| 34 | − "cost_goods": 3.121949371227848, | 84 | + "cost_goods": 67.28423159055245, | ||
| 35 | − "cost_incl": 23.12194937122785 | 85 | + "cost_incl": 87.28423159055245 | ||
| 36 | } | 86 | } | ||
| 37 | ] | 87 | ] | ||
| 38 | } | 88 | } | ||
| \ No newline at end of file | |||||
added
experiments/qgpai_summary.json
+225 −0
@@ -0,0 +1,225 @@ | |||||
| 1 | +{ | ||||
| 2 | + "components": [ | ||||
| 3 | + { | ||||
| 4 | + "week": "2026-08-13 00:00:00", | ||||
| 5 | + "comparable_price_index": 100.0, | ||||
| 6 | + "cpi_lo": 100.0, | ||||
| 7 | + "cpi_hi": 100.0, | ||||
| 8 | + "dispersion_median_gap": 0.1546, | ||||
| 9 | + "disp_lo": 0.1532, | ||||
| 10 | + "disp_hi": 0.1648, | ||||
| 11 | + "promo_share": NaN, | ||||
| 12 | + "depth_mean": NaN, | ||||
| 13 | + "depth_sd": NaN, | ||||
| 14 | + "basket_K1": 34.1664, | ||||
| 15 | + "basket_K2": 31.4286, | ||||
| 16 | + "basket_unconstrained": 29.5234, | ||||
| 17 | + "circular_depth_median": 0.2236, | ||||
| 18 | + "circular_items": 469, | ||||
| 19 | + "access_affordability": NaN, | ||||
| 20 | + "composite_equal_illustrative": NaN, | ||||
| 21 | + "composite_alt_illustrative": 0.6251 | ||||
| 22 | + }, | ||||
| 23 | + { | ||||
| 24 | + "week": "2026-08-20 00:00:00", | ||||
| 25 | + "comparable_price_index": 99.654, | ||||
| 26 | + "cpi_lo": 99.2336, | ||||
| 27 | + "cpi_hi": 100.0892, | ||||
| 28 | + "dispersion_median_gap": 0.1542, | ||||
| 29 | + "disp_lo": 0.1487, | ||||
| 30 | + "disp_hi": 0.1546, | ||||
| 31 | + "promo_share": 0.0427, | ||||
| 32 | + "depth_mean": 0.2905, | ||||
| 33 | + "depth_sd": 0.1901, | ||||
| 34 | + "basket_K1": 32.4038, | ||||
| 35 | + "basket_K2": 29.7347, | ||||
| 36 | + "basket_unconstrained": 28.951, | ||||
| 37 | + "circular_depth_median": 0.2241, | ||||
| 38 | + "circular_items": 483, | ||||
| 39 | + "access_affordability": NaN, | ||||
| 40 | + "composite_equal_illustrative": 0.1338, | ||||
| 41 | + "composite_alt_illustrative": -0.347 | ||||
| 42 | + }, | ||||
| 43 | + { | ||||
| 44 | + "week": "2026-08-27 00:00:00", | ||||
| 45 | + "comparable_price_index": 98.6705, | ||||
| 46 | + "cpi_lo": 98.1883, | ||||
| 47 | + "cpi_hi": 99.187, | ||||
| 48 | + "dispersion_median_gap": 0.1544, | ||||
| 49 | + "disp_lo": 0.1542, | ||||
| 50 | + "disp_hi": 0.1546, | ||||
| 51 | + "promo_share": 0.029, | ||||
| 52 | + "depth_mean": 0.3293, | ||||
| 53 | + "depth_sd": 0.2052, | ||||
| 54 | + "basket_K1": 34.8666, | ||||
| 55 | + "basket_K2": 31.0668, | ||||
| 56 | + "basket_unconstrained": 29.5677, | ||||
| 57 | + "circular_depth_median": 0.2463, | ||||
| 58 | + "circular_items": 486, | ||||
| 59 | + "access_affordability": NaN, | ||||
| 60 | + "composite_equal_illustrative": 0.6202, | ||||
| 61 | + "composite_alt_illustrative": -0.0335 | ||||
| 62 | + }, | ||||
| 63 | + { | ||||
| 64 | + "week": "2026-09-03 00:00:00", | ||||
| 65 | + "comparable_price_index": 99.4984, | ||||
| 66 | + "cpi_lo": 99.0486, | ||||
| 67 | + "cpi_hi": 99.9052, | ||||
| 68 | + "dispersion_median_gap": 0.1544, | ||||
| 69 | + "disp_lo": 0.1512, | ||||
| 70 | + "disp_hi": 0.1546, | ||||
| 71 | + "promo_share": 0.0436, | ||||
| 72 | + "depth_mean": 0.2906, | ||||
| 73 | + "depth_sd": 0.1997, | ||||
| 74 | + "basket_K1": 33.0893, | ||||
| 75 | + "basket_K2": 29.2395, | ||||
| 76 | + "basket_unconstrained": 27.9327, | ||||
| 77 | + "circular_depth_median": 0.2303, | ||||
| 78 | + "circular_items": 530, | ||||
| 79 | + "access_affordability": NaN, | ||||
| 80 | + "composite_equal_illustrative": 0.2142, | ||||
| 81 | + "composite_alt_illustrative": -0.1756 | ||||
| 82 | + }, | ||||
| 83 | + { | ||||
| 84 | + "week": "2026-09-10 00:00:00", | ||||
| 85 | + "comparable_price_index": 100.0647, | ||||
| 86 | + "cpi_lo": 99.5442, | ||||
| 87 | + "cpi_hi": 100.4976, | ||||
| 88 | + "dispersion_median_gap": 0.1544, | ||||
| 89 | + "disp_lo": 0.1464, | ||||
| 90 | + "disp_hi": 0.1546, | ||||
| 91 | + "promo_share": 0.0425, | ||||
| 92 | + "depth_mean": 0.2885, | ||||
| 93 | + "depth_sd": 0.2047, | ||||
| 94 | + "basket_K1": 31.8767, | ||||
| 95 | + "basket_K2": 29.6128, | ||||
| 96 | + "basket_unconstrained": 28.1203, | ||||
| 97 | + "circular_depth_median": 0.237, | ||||
| 98 | + "circular_items": 590, | ||||
| 99 | + "access_affordability": NaN, | ||||
| 100 | + "composite_equal_illustrative": 0.1921, | ||||
| 101 | + "composite_alt_illustrative": -0.2716 | ||||
| 102 | + }, | ||||
| 103 | + { | ||||
| 104 | + "week": "2026-09-17 00:00:00", | ||||
| 105 | + "comparable_price_index": 100.2457, | ||||
| 106 | + "cpi_lo": 99.6763, | ||||
| 107 | + "cpi_hi": 100.7577, | ||||
| 108 | + "dispersion_median_gap": 0.1527, | ||||
| 109 | + "disp_lo": 0.1432, | ||||
| 110 | + "disp_hi": 0.1544, | ||||
| 111 | + "promo_share": 0.0461, | ||||
| 112 | + "depth_mean": 0.3141, | ||||
| 113 | + "depth_sd": 0.275, | ||||
| 114 | + "basket_K1": 32.1657, | ||||
| 115 | + "basket_K2": 29.616, | ||||
| 116 | + "basket_unconstrained": 28.281, | ||||
| 117 | + "circular_depth_median": 0.2236, | ||||
| 118 | + "circular_items": 589, | ||||
| 119 | + "access_affordability": NaN, | ||||
| 120 | + "composite_equal_illustrative": 0.1232, | ||||
| 121 | + "composite_alt_illustrative": -0.0236 | ||||
| 122 | + }, | ||||
| 123 | + { | ||||
| 124 | + "week": "2026-09-24 00:00:00", | ||||
| 125 | + "comparable_price_index": 100.3667, | ||||
| 126 | + "cpi_lo": 99.7837, | ||||
| 127 | + "cpi_hi": 100.8567, | ||||
| 128 | + "dispersion_median_gap": 0.1527, | ||||
| 129 | + "disp_lo": 0.1433, | ||||
| 130 | + "disp_hi": 0.1544, | ||||
| 131 | + "promo_share": 0.0516, | ||||
| 132 | + "depth_mean": 0.3044, | ||||
| 133 | + "depth_sd": 0.2801, | ||||
| 134 | + "basket_K1": 32.3216, | ||||
| 135 | + "basket_K2": 28.4099, | ||||
| 136 | + "basket_unconstrained": 27.1763, | ||||
| 137 | + "circular_depth_median": 0.2236, | ||||
| 138 | + "circular_items": 595, | ||||
| 139 | + "access_affordability": NaN, | ||||
| 140 | + "composite_equal_illustrative": 0.072, | ||||
| 141 | + "composite_alt_illustrative": 0.1267 | ||||
| 142 | + }, | ||||
| 143 | + { | ||||
| 144 | + "week": "2026-10-01 00:00:00", | ||||
| 145 | + "comparable_price_index": 100.802, | ||||
| 146 | + "cpi_lo": 100.2437, | ||||
| 147 | + "cpi_hi": 101.3218, | ||||
| 148 | + "dispersion_median_gap": 0.1511, | ||||
| 149 | + "disp_lo": 0.1404, | ||||
| 150 | + "disp_hi": 0.1544, | ||||
| 151 | + "promo_share": 0.0578, | ||||
| 152 | + "depth_mean": 0.2972, | ||||
| 153 | + "depth_sd": 0.2488, | ||||
| 154 | + "basket_K1": 31.0905, | ||||
| 155 | + "basket_K2": 27.8308, | ||||
| 156 | + "basket_unconstrained": 26.9732, | ||||
| 157 | + "circular_depth_median": 0.2088, | ||||
| 158 | + "circular_items": 692, | ||||
| 159 | + "access_affordability": NaN, | ||||
| 160 | + "composite_equal_illustrative": -0.272, | ||||
| 161 | + "composite_alt_illustrative": -0.0701 | ||||
| 162 | + }, | ||||
| 163 | + { | ||||
| 164 | + "week": "2026-10-08 00:00:00", | ||||
| 165 | + "comparable_price_index": 100.8131, | ||||
| 166 | + "cpi_lo": 100.207, | ||||
| 167 | + "cpi_hi": 101.3248, | ||||
| 168 | + "dispersion_median_gap": 0.1513, | ||||
| 169 | + "disp_lo": 0.1427, | ||||
| 170 | + "disp_hi": 0.1544, | ||||
| 171 | + "promo_share": 0.0678, | ||||
| 172 | + "depth_mean": 0.2722, | ||||
| 173 | + "depth_sd": 0.2434, | ||||
| 174 | + "basket_K1": 31.1654, | ||||
| 175 | + "basket_K2": 27.6367, | ||||
| 176 | + "basket_unconstrained": 26.6867, | ||||
| 177 | + "circular_depth_median": 0.2236, | ||||
| 178 | + "circular_items": 737, | ||||
| 179 | + "access_affordability": NaN, | ||||
| 180 | + "composite_equal_illustrative": -0.4719, | ||||
| 181 | + "composite_alt_illustrative": -0.0313 | ||||
| 182 | + }, | ||||
| 183 | + { | ||||
| 184 | + "week": "2026-10-15 00:00:00", | ||||
| 185 | + "comparable_price_index": 101.3078, | ||||
| 186 | + "cpi_lo": 100.789, | ||||
| 187 | + "cpi_hi": 101.8291, | ||||
| 188 | + "dispersion_median_gap": 0.1407, | ||||
| 189 | + "disp_lo": 0.1336, | ||||
| 190 | + "disp_hi": 0.1516, | ||||
| 191 | + "promo_share": 0.0636, | ||||
| 192 | + "depth_mean": 0.2608, | ||||
| 193 | + "depth_sd": 0.2192, | ||||
| 194 | + "basket_K1": 31.1654, | ||||
| 195 | + "basket_K2": 27.1084, | ||||
| 196 | + "basket_unconstrained": 27.1084, | ||||
| 197 | + "circular_depth_median": 0.2371, | ||||
| 198 | + "circular_items": 661, | ||||
| 199 | + "access_affordability": NaN, | ||||
| 200 | + "composite_equal_illustrative": -0.8951, | ||||
| 201 | + "composite_alt_illustrative": 0.321 | ||||
| 202 | + }, | ||||
| 203 | + { | ||||
| 204 | + "week": "2026-10-22 00:00:00", | ||||
| 205 | + "comparable_price_index": 100.6884, | ||||
| 206 | + "cpi_lo": 100.1056, | ||||
| 207 | + "cpi_hi": 101.2119, | ||||
| 208 | + "dispersion_median_gap": 0.1513, | ||||
| 209 | + "disp_lo": 0.1368, | ||||
| 210 | + "disp_hi": 0.1544, | ||||
| 211 | + "promo_share": 0.0541, | ||||
| 212 | + "depth_mean": 0.2877, | ||||
| 213 | + "depth_sd": 0.2573, | ||||
| 214 | + "basket_K1": 31.1654, | ||||
| 215 | + "basket_K2": 27.9066, | ||||
| 216 | + "basket_unconstrained": 27.9066, | ||||
| 217 | + "circular_depth_median": 0.2291, | ||||
| 218 | + "circular_items": 809, | ||||
| 219 | + "access_affordability": NaN, | ||||
| 220 | + "composite_equal_illustrative": -0.2028, | ||||
| 221 | + "composite_alt_illustrative": -0.1201 | ||||
| 222 | + } | ||||
| 223 | + ], | ||||
| 224 | + "note": "component 5 not computed; composites are illustrations with prespecified weights, not estimates" | ||||
| 225 | +} | ||||
| \ No newline at end of file | |||||
added
experiments/robustness_summary.json
+68 −0
@@ -0,0 +1,68 @@ | |||||
| 1 | +{ | ||||
| 2 | + "baseline_median_gap": 0.15415067982725827, | ||||
| 3 | + "balanced": { | ||||
| 4 | + "groups": 1300, | ||||
| 5 | + "median_gap": 0.1634061098014189, | ||||
| 6 | + "share_ge_5pct": 0.8048251748251748 | ||||
| 7 | + }, | ||||
| 8 | + "unit_price": { | ||||
| 9 | + "group_weeks": 29997, | ||||
| 10 | + "median_gap": 0.15415067982725839, | ||||
| 11 | + "share_ge_5pct": 0.7852118545187852 | ||||
| 12 | + }, | ||||
| 13 | + "trimmed_1_99": { | ||||
| 14 | + "median_gap": 0.15128944754622609, | ||||
| 15 | + "mean_gap": 0.18149910019754395, | ||||
| 16 | + "mean_untrimmed": 0.18936608290604118 | ||||
| 17 | + }, | ||||
| 18 | + "cross_company_only": { | ||||
| 19 | + "group_weeks": 2103, | ||||
| 20 | + "median_gap": 0.18254405095440074 | ||||
| 21 | + }, | ||||
| 22 | + "non_promo_weeks_only": { | ||||
| 23 | + "group_weeks": 26442, | ||||
| 24 | + "median_gap": 0.13540463700620298, | ||||
| 25 | + "share_ge_5pct": 0.769760229937221 | ||||
| 26 | + }, | ||||
| 27 | + "group_weeks_with_a_promo": { | ||||
| 28 | + "group_weeks": 4813, | ||||
| 29 | + "median_gap": 0.23709263169173989 | ||||
| 30 | + }, | ||||
| 31 | + "holm": [ | ||||
| 32 | + { | ||||
| 33 | + "test": "H1_median_gap_gt_5pct", | ||||
| 34 | + "p": 0.0, | ||||
| 35 | + "p_holm": 0.0 | ||||
| 36 | + }, | ||||
| 37 | + { | ||||
| 38 | + "test": "H1_AR1_gt_0.5", | ||||
| 39 | + "p": 0.0, | ||||
| 40 | + "p_holm": 0.0 | ||||
| 41 | + }, | ||||
| 42 | + { | ||||
| 43 | + "test": "H2_ratio_ne_1", | ||||
| 44 | + "p": 0.0, | ||||
| 45 | + "p_holm": 0.0 | ||||
| 46 | + }, | ||||
| 47 | + { | ||||
| 48 | + "test": "H3_all_low_price_episodes_dlog_ref_ne_0", | ||||
| 49 | + "p": 0.0, | ||||
| 50 | + "p_holm": 0.0 | ||||
| 51 | + }, | ||||
| 52 | + { | ||||
| 53 | + "test": "H4_lag1_ne_0", | ||||
| 54 | + "p": 0.002762665364015371, | ||||
| 55 | + "p_holm": 0.008287996092046113 | ||||
| 56 | + }, | ||||
| 57 | + { | ||||
| 58 | + "test": "H3_return_episodes_dlog_ref_ne_0", | ||||
| 59 | + "p": 0.00912808030023271, | ||||
| 60 | + "p_holm": 0.01825616060046542 | ||||
| 61 | + }, | ||||
| 62 | + { | ||||
| 63 | + "test": "H5_naive_vs_jevons_gap_ne_0", | ||||
| 64 | + "p": 1.0, | ||||
| 65 | + "p_holm": 1.0 | ||||
| 66 | + } | ||||
| 67 | + ] | ||||
| 68 | +} | ||||
| \ No newline at end of file | |||||
added
paper/figures/fig_basket.pdf
+0 −0
Binary file not shown.
added
paper/figures/fig_coverage.pdf
+0 −0
Binary file not shown.
added
paper/figures/fig_dispersion.pdf
+0 −0
Binary file not shown.
added
paper/figures/fig_forecast.pdf
+0 −0
Binary file not shown.
added
paper/figures/fig_heatmap.pdf
+0 −0
Binary file not shown.
added
paper/figures/fig_indices.pdf
+0 −0
Binary file not shown.
added
paper/figures/fig_promotions.pdf
+0 −0
Binary file not shown.
added
paper/figures/fig_qgpai.pdf
+0 −0
Binary file not shown.
added
paper/figures/fig_spillovers.pdf
+0 −0
Binary file not shown.
added
paper/figures/fig_store_mixture.pdf
+0 −0
Binary file not shown.
added
paper/tables/tab_attrition.tex
+23 −0
@@ -0,0 +1,23 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{Listing-level sample attrition}\label{tab:attrition} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{p{9cm}rr} | ||||
| 6 | +\toprule | ||||
| 7 | +Step & Listings & Share of initial \\ | ||||
| 8 | +\midrule | ||||
| 9 | +All listings in snapshot (products table) & 124\,579 & 1.000 \\ | ||||
| 10 | +excl. SAQ (alcohol monopoly) & 117\,102 & 0.940 \\ | ||||
| 11 | +excl. pharmacy feeds & 115\,352 & 0.926 \\ | ||||
| 12 | +excl. non-food categories (health \& beauty, household, pets, baby, alcohol) & 112\,233 & 0.901 \\ | ||||
| 13 | +with at least one observed price & 111\,924 & 0.898 \\ | ||||
| 14 | +excl. unrealistic prices & 111\,575 & 0.896 \\ | ||||
| 15 | +… with parsable package size (unit-price / matching universe) & 66\,340 & 0.533 \\ | ||||
| 16 | +… in a cross-banner match group (tiers A–C) & 13\,273 & 0.107 \\ | ||||
| 17 | +\bottomrule | ||||
| 18 | +\end{tabular} | ||||
| 19 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 20 | +\item Each row applies the stated filter to the previous row. Matching tiers A--C shown; the primary analysis uses A+B. | ||||
| 21 | +\end{tablenotes} | ||||
| 22 | +\end{threeparttable} | ||||
| 23 | +\end{table} | ||||
added
paper/tables/tab_banner_effects.tex
+29 −0
@@ -0,0 +1,29 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{Banner fixed effects on log posted price within product-week (reference: Metro)}\label{tab:bannerfe} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{lrrrr} | ||||
| 6 | +\toprule | ||||
| 7 | +Banner & Coefficient & s.e. (group) & s.e. (group+banner) & Listing-weeks \\ | ||||
| 8 | +\midrule | ||||
| 9 | +Walmart & -0.202 & 0.037 & 0.032 & 34 \\ | ||||
| 10 | +Giant Tiger & -0.189 & 0.052 & 0.039 & 265 \\ | ||||
| 11 | +Maxi & -0.184 & 0.016 & 0.027 & 10\,844 \\ | ||||
| 12 | +Club Entrepôt & -0.136 & 0.016 & 0.027 & 9\,214 \\ | ||||
| 13 | +Aubut & -0.135 & 0.030 & 0.023 & 293 \\ | ||||
| 14 | +IGA & -0.120 & 0.055 & 0.041 & 226 \\ | ||||
| 15 | +Super C & -0.100 & 0.003 & 0.003 & 17\,329 \\ | ||||
| 16 | +Provigo & -0.043 & 0.016 & 0.027 & 10\,405 \\ | ||||
| 17 | +Avril & -0.012 & 0.028 & 0.021 & 346 \\ | ||||
| 18 | +Mayrand & 0.003 & 0.021 & 0.018 & 100 \\ | ||||
| 19 | +Marché Tau & 0.016 & 0.036 & 0.031 & 58 \\ | ||||
| 20 | +Maturin & 0.082 & 0.028 & 0.021 & 4 \\ | ||||
| 21 | +La Boîte à Grains & 0.086 & 0.026 & 0.019 & 601 \\ | ||||
| 22 | +Marché Nuvo & 0.121 & 0.066 & 0.054 & 92 \\ | ||||
| 23 | +\bottomrule | ||||
| 24 | +\end{tabular} | ||||
| 25 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 26 | +\item Model A: $\log p_{irw} = \alpha_{iw} + \gamma_r + \varepsilon_{irw}$ on matched groups (tiers A+B), storefront channel, non-ambiguous group-weeks. Standard errors clustered by match group, and two-way by group and banner. Coefficients are relative posted-price levels for the same product in the same week; banners with few listing-weeks are imprecisely estimated. | ||||
| 27 | +\end{tablenotes} | ||||
| 28 | +\end{threeparttable} | ||||
| 29 | +\end{table} | ||||
added
paper/tables/tab_basket.tex
+19 −0
@@ -0,0 +1,19 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{Fixed-basket cost under banner-count constraints (Model E, weekly means over 11 weeks, CAD)}\label{tab:basket} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{lrrrrrr} | ||||
| 6 | +\toprule | ||||
| 7 | +Banners (B) & Items & Cost K=1 & Cost K=2 & Cost unconstrained & Savings share & Share captured at K=2 \\ | ||||
| 8 | +\midrule | ||||
| 9 | +4 & 18 & 78.72 & 68.97 & 66.86 & 0.15 & 0.82 \\ | ||||
| 10 | +5 & 15 & 67.28 & 58.50 & 56.92 & 0.15 & 0.85 \\ | ||||
| 11 | +6 & 11 & 49.27 & 45.05 & 43.66 & 0.11 & 0.75 \\ | ||||
| 12 | +7 & 9 & 31.01 & 28.33 & 27.28 & 0.12 & 0.72 \\ | ||||
| 13 | +\bottomrule | ||||
| 14 | +\end{tabular} | ||||
| 15 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 16 | +\item Complete-block design (amendment A-001): the $B$ widest-coverage storefront banners and the basket items all of them carry; item cost = cheapest unit price $\times$ reference quantity. K = 1: best single banner. Travel costs are unobserved; with an illustrative fixed cost per banner of 5 / 10 / 20 CAD the optimum uses 1.82 / 1.27 / 1.00 banners on average. Spread between the cheapest and most expensive single banner for the same items: 91\% of the cheapest. | ||||
| 17 | +\end{tablenotes} | ||||
| 18 | +\end{threeparttable} | ||||
| 19 | +\end{table} | ||||
added
paper/tables/tab_coverage_feeds.tex
+39 −0
@@ -0,0 +1,39 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{Coverage of the 24 largest source feeds (snapshot of 1 November 2026)}\label{tab:coverage} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{lrrrrrrrr} | ||||
| 6 | +\toprule | ||||
| 7 | +Company & Banner & Channel & Listings & Active & Median days & Price events & Unit price & Brand \\ | ||||
| 8 | +\midrule | ||||
| 9 | +Metro Inc. & Metro & online\_storefront & 16\,382 & 1\,540 & 50.00 & 63868.00 & 0.91 & 0.88 \\ | ||||
| 10 | +Giant Tiger Stores & Giant Tiger & online\_storefront & 10\,352 & 9\,617 & 81.00 & 16810.00 & 0.74 & 1.00 \\ | ||||
| 11 | +Société des alcools du Québec & SAQ & online\_storefront & 7\,477 & 3\,293 & 36.00 & 8153.00 & 1.00 & 1.00 \\ | ||||
| 12 | +Metro Inc. & Super C & online\_storefront & 6\,418 & 857 & 48.00 & 15210.00 & 0.94 & 0.93 \\ | ||||
| 13 | +La Boîte à Grains & La Boîte à Grains & online\_storefront & 6\,279 & 6\,000 & 75.00 & 11903.00 & 0.76 & 1.00 \\ | ||||
| 14 | +Marché Nuvo & Marché Nuvo & online\_storefront & 6\,224 & 4\,970 & 75.00 & 8186.00 & 0.97 & 1.00 \\ | ||||
| 15 | +Epipresto & Epipresto & online\_storefront & 6\,000 & 6\,000 & 75.00 & 5984.00 & 0.32 & 1.00 \\ | ||||
| 16 | +Walmart Canada & Walmart & online\_storefront & 4\,601 & 922 & 19.00 & 7872.00 & 0.56 & 0.28 \\ | ||||
| 17 | +Supermarché PA & PA Supermarché & online\_storefront & 3\,602 & 3\,554 & 81.00 & 6864.00 & 0.19 & 1.00 \\ | ||||
| 18 | +Walmart Canada & Walmart & circular & 3\,330 & 219 & 8.00 & 3501.00 & 0.09 & 0.66 \\ | ||||
| 19 | +Sobeys / Empire & IGA & online\_storefront & 3\,083 & 600 & 34.00 & 5848.00 & 0.95 & 0.94 \\ | ||||
| 20 | +Metro Inc. & Marché Adonis & online\_storefront & 2\,950 & 447 & 52.00 & 5935.00 & 0.93 & 0.00 \\ | ||||
| 21 | +Sobeys / Empire & IGA & circular & 2\,933 & 350 & 9.00 & 3527.00 & 0.03 & 0.75 \\ | ||||
| 22 | +Aliments Merci & Aliments Merci & online\_storefront & 2\,859 & 2\,838 & 75.00 & 3367.00 & 0.05 & 0.85 \\ | ||||
| 23 | +Loblaw Companies & Club Entrepôt & online\_storefront & 2\,808 & 763 & 74.00 & 68438.00 & 1.00 & 0.76 \\ | ||||
| 24 | +Loblaw Companies & Maxi & online\_storefront & 2\,441 & 1\,086 & 78.00 & 13674.00 & 1.00 & 0.82 \\ | ||||
| 25 | +Metro Inc. & Metro & circular & 2\,256 & 314 & 9.00 & 2871.00 & 0.01 & 0.00 \\ | ||||
| 26 | +Loblaw Companies & Provigo & online\_storefront & 2\,138 & 450 & 77.00 & 5582.00 & 1.00 & 0.80 \\ | ||||
| 27 | +Loblaw Companies & Maxi & circular & 2\,107 & 271 & 9.00 & 2341.00 & 0.62 & 0.85 \\ | ||||
| 28 | +Metro Inc. & Super C & circular & 1\,954 & 192 & 9.00 & 2448.00 & 0.00 & 0.30 \\ | ||||
| 29 | +Loblaw Companies & Provigo & circular & 1\,914 & 175 & 9.00 & 2035.00 & 0.55 & 0.79 \\ | ||||
| 30 | +Metro Inc. & Marché Adonis & circular & 1\,882 & 203 & 9.00 & 2131.00 & 0.01 & 0.62 \\ | ||||
| 31 | +Pasquier & Pasquier & circular & 1\,611 & 152 & 9.00 & 1727.00 & 0.21 & 0.75 \\ | ||||
| 32 | +Loblaw Companies & Pharmaprix & circular & 1\,225 & 167 & 8.00 & 1378.00 & 0.51 & 0.51 \\ | ||||
| 33 | +\bottomrule | ||||
| 34 | +\end{tabular} | ||||
| 35 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 36 | +\item Listings = all uids ever observed in the feed (active and deactivated); Active = listed on 1 Nov 2026; Median days = median observation span; Price events = rows in the price log; Unit price / Brand = share of active listings with a parsable package size (re-parsed) / a brand field. Channel: online storefront vs weekly circular (Flipp, postal code H2X 1Y4). Full table of 54 feeds in the replication package. | ||||
| 37 | +\end{tablenotes} | ||||
| 38 | +\end{threeparttable} | ||||
| 39 | +\end{table} | ||||
added
paper/tables/tab_dispersion.tex
+18 −0
@@ -0,0 +1,18 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{Within-product cross-banner log price gaps (max$-$min), tiers A+B, storefront channel}\label{tab:dispersion} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{lrrrrrrr} | ||||
| 6 | +\toprule | ||||
| 7 | +Sample & Group-weeks & Median & P25 & P75 & Mean & Share $\ge$ 5\\% & Share $\ge$ 10\\% \\ | ||||
| 8 | +\midrule | ||||
| 9 | +All group-weeks & 30\,042 & 0.154 & 0.059 & 0.279 & 0.189 & 0.785 & 0.633 \\ | ||||
| 10 | +Same company (two banners) & 27\,939 & 0.151 & 0.058 & 0.268 & 0.186 & 0.782 & 0.627 \\ | ||||
| 11 | +Different companies & 2\,103 & 0.183 & 0.080 & 0.337 & 0.237 & 0.825 & 0.701 \\ | ||||
| 12 | +\bottomrule | ||||
| 13 | +\end{tabular} | ||||
| 14 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 15 | +\item Weekly posted price = duration-weighted modal price. Residual s.d. of the product-week/banner two-way fixed-effects model: 0.115 (N = 67,732). AR(1) of banner-pair gaps: 0.663 (s.e. 0.014); placebo with banner labels permuted within group-week: 0.004 (s.d. 0.011). Share of banner pairs ($\ge$ 4 weeks) whose sign never flips: 0.46. | ||||
| 16 | +\end{tablenotes} | ||||
| 17 | +\end{threeparttable} | ||||
| 18 | +\end{table} | ||||
added
paper/tables/tab_forecast_price.tex
+20 −0
@@ -0,0 +1,20 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{Rolling-origin forecasts of next-week log posted price (storefront listings)}\label{tab:fprice} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{lrrrr} | ||||
| 6 | +\toprule | ||||
| 7 | +Model & Origins & N & MAE & RMSE \\ | ||||
| 8 | +\midrule | ||||
| 9 | +banner\_cat\_drift & 4 & 215\,264 & 0.0248 & 0.1084 \\ | ||||
| 10 | +lightgbm & 4 & 215\,264 & 0.0301 & 0.1000 \\ | ||||
| 11 | +mean\_reversion\_ref & 4 & 215\,264 & 0.0284 & 0.1025 \\ | ||||
| 12 | +naive\_last & 4 & 215\,264 & 0.0226 & 0.1083 \\ | ||||
| 13 | +ridge & 4 & 215\,264 & 0.0299 & 0.0992 \\ | ||||
| 14 | +\bottomrule | ||||
| 15 | +\end{tabular} | ||||
| 16 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 17 | +\item Strict time-based holdouts: train on weeks $\le t-1$, test on week $t$, origins $t = 6,\dots,9$; features are lags only. Lower is better. | ||||
| 18 | +\end{tablenotes} | ||||
| 19 | +\end{threeparttable} | ||||
| 20 | +\end{table} | ||||
added
paper/tables/tab_forecast_promo.tex
+20 −0
@@ -0,0 +1,20 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{Rolling-origin forecasts of next-week promotion state}\label{tab:fpromo} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{lrrrr} | ||||
| 6 | +\toprule | ||||
| 7 | +Model & Origins & N & Brier & AUC \\ | ||||
| 8 | +\midrule | ||||
| 9 | +base\_rate & 4 & 215\,264 & 0.0568 & 0.5000 \\ | ||||
| 10 | +hist\_rate & 4 & 215\,264 & 0.0572 & 0.6778 \\ | ||||
| 11 | +lightgbm & 4 & 215\,264 & 0.0313 & 0.9359 \\ | ||||
| 12 | +logit & 4 & 215\,264 & 0.0439 & 0.8940 \\ | ||||
| 13 | +persist & 4 & 215\,264 & 0.0510 & 0.7519 \\ | ||||
| 14 | +\bottomrule | ||||
| 15 | +\end{tabular} | ||||
| 16 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 17 | +\item Promotion = modal price $\le 0.9\times$ trailing 8-week mode. Brier: lower is better; AUC: higher is better. Base rate $\approx$ 5\%. | ||||
| 18 | +\end{tablenotes} | ||||
| 19 | +\end{threeparttable} | ||||
| 20 | +\end{table} | ||||
added
paper/tables/tab_holm.tex
+22 −0
@@ -0,0 +1,22 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{Preregistered primary tests with Holm correction}\label{tab:holm} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{p{8cm}rr} | ||||
| 6 | +\toprule | ||||
| 7 | +Preregistered test & p & Holm-adjusted p \\ | ||||
| 8 | +\midrule | ||||
| 9 | +H1\_median\_gap\_gt\_5pct & 0.0000 & 0.0000 \\ | ||||
| 10 | +H1\_AR1\_gt\_0.5 & 0.0000 & 0.0000 \\ | ||||
| 11 | +H2\_ratio\_ne\_1 & 0.0000 & 0.0000 \\ | ||||
| 12 | +H3\_all\_low\_price\_episodes\_dlog\_ref\_ne\_0 & 0.0000 & 0.0000 \\ | ||||
| 13 | +H4\_lag1\_ne\_0 & 0.0028 & 0.0083 \\ | ||||
| 14 | +H3\_return\_episodes\_dlog\_ref\_ne\_0 & 0.0091 & 0.0183 \\ | ||||
| 15 | +H5\_naive\_vs\_jevons\_gap\_ne\_0 & 1.0000 & 1.0000 \\ | ||||
| 16 | +\bottomrule | ||||
| 17 | +\end{tabular} | ||||
| 18 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 19 | +\item CI-based entries take p = 0 when the bootstrap 95\% CI excludes the null value and 1 otherwise (conservative coding). | ||||
| 20 | +\end{tablenotes} | ||||
| 21 | +\end{threeparttable} | ||||
| 22 | +\end{table} | ||||
added
paper/tables/tab_indices.tex
+18 −0
@@ -0,0 +1,18 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{Cumulative price change over 11 weeks (13 Aug $\to$ 22 Oct 2026) under alternative unweighted index formulas}\label{tab:indices} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{lrrrrrrr} | ||||
| 6 | +\toprule | ||||
| 7 | +Sample & Naive mean & Naive median & Chained Jevons & Fixed Jevons & Fixed Dutot & Fixed Carli & Balanced n \\ | ||||
| 8 | +\midrule | ||||
| 9 | +Storefront, posted price & 0.004 & 0.000 & 0.003 & 0.001 & -0.001 & 0.027 & 44\,423 \\ | ||||
| 10 | +Storefront, unit price & -0.012 & -0.007 & 0.003 & 0.001 & -0.004 & 0.036 & 29\,671 \\ | ||||
| 11 | +All channels, posted price & -0.029 & 0.000 & 0.002 & 0.001 & -0.001 & 0.028 & 45\,214 \\ | ||||
| 12 | +\bottomrule | ||||
| 13 | +\end{tabular} | ||||
| 14 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 15 | +\item No expenditure weights exist: none of these is a consumer price index. Bootstrap (999 listing resamples) 95\% CI for the storefront chained Jevons: [0.002, 0.004]; naive-mean minus Jevons gap: 0.001 [-0.008, 0.008]. Statistics Canada CPI, Quebec, food purchased from stores: Aug$\to$Sep 2026 -0.007 (our chained Jevons, monthly means, +0.001); 12-month CPI change to Sep 2026 +0.016. | ||||
| 16 | +\end{tablenotes} | ||||
| 17 | +\end{threeparttable} | ||||
| 18 | +\end{table} | ||||
added
paper/tables/tab_match_validation.tex
+19 −0
@@ -0,0 +1,19 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{Matching validation: stratified sample (60 pairs per tier) and UPC ground truth}\label{tab:validation} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{lrrrrrrrrr} | ||||
| 6 | +\toprule | ||||
| 7 | +Tier & n & Same & Different & Unsure & Precision & CI low & CI high & UPC precision & UPC recall \\ | ||||
| 8 | +\midrule | ||||
| 9 | +A & 60 & 60 & 0 & 0 & 1.000 & 0.940 & 1.000 & 1.000 & \\ | ||||
| 10 | +B & 60 & 60 & 0 & 0 & 1.000 & 0.940 & 1.000 & 0.913 & 0.984 \\ | ||||
| 11 | +C & 60 & 27 & 30 & 3 & 0.450 & 0.331 & 0.575 & 0.845 & 0.989 \\ | ||||
| 12 | +D & 60 & 59 & 0 & 1 & 0.983 & 0.911 & 0.997 & & \\ | ||||
| 13 | +\bottomrule | ||||
| 14 | +\end{tabular} | ||||
| 15 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 16 | +\item Precision = share labelled SAME (Wilson 95\% CI); labels by Claude (claude-opus-5-5) as second annotator with full operator review of every pair; PI adjudication pending. UPC columns: precision/recall of the tier's text rule on all Metro\,$\times$\,Super~C candidate pairs within brand\,$\times$\,package blocks, where the shared GS1 code is the truth (B: identical token sets; C: token-sort ratio $\ge 0.90$). Tier D has no UPC benchmark. | ||||
| 17 | +\end{tablenotes} | ||||
| 18 | +\end{threeparttable} | ||||
| 19 | +\end{table} | ||||
added
paper/tables/tab_promotions.tex
+33 −0
@@ -0,0 +1,33 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{Promotion dynamics in storefront feeds (Model B)}\label{tab:promotions} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{p{10.5cm}r} | ||||
| 6 | +\toprule | ||||
| 7 | +Statistic & Value \\ | ||||
| 8 | +\midrule | ||||
| 9 | +Listing-weeks promoted (inferred, $p \le 0.9\times$ trailing 8-week mode) & 0.046 \\ | ||||
| 10 | +Median depth (log ref/p), promoted weeks & 0.224 \\ | ||||
| 11 | +Agreement with current on-sale flag (last week) & 0.902 \\ | ||||
| 12 | +S.d. weekly $\Delta\log p$, raw & 0.106 \\ | ||||
| 13 | +S.d. weekly $\Delta\log$ reference & 0.055 \\ | ||||
| 14 | +Ratio raw / reference (bootstrap 95\\% CI) & 1.92 [1.84, 2.02] \\ | ||||
| 15 | +Share of weekly price changes that are promotion transitions & 0.504 \\ | ||||
| 16 | +Episodes (uncensored) & 14,774 (11,705) \\ | ||||
| 17 | +Episode kinds: return / reset down / reset up & 0.52 / 0.42 / 0.06 \\ | ||||
| 18 | +Return episodes: median duration (weeks), share 1 week & 1, 0.80 \\ | ||||
| 19 | +Return episodes: median depth & 0.288 \\ | ||||
| 20 | +Median recurrence interval (weeks), share of listings with $\ge$ 2 episodes & 3, 0.23 \\ | ||||
| 21 | +H3 (return episodes): mean $\Delta\log$ reference after vs before (s.e.) & -0.0003 (0.0001), n = 6,049 \\ | ||||
| 22 | +H3 (all low-price episodes): mean $\Delta\log$ reference (s.e.) & -0.1018 (0.0018) \\ | ||||
| 23 | +Placebo (random weeks, non-promoted listings) & 0.0003 (0.0007) \\ | ||||
| 24 | +Event-study pretrend test (weeks $-4..-2$), $p$ & 0.000 \\ | ||||
| 25 | +Circular items with observed regular price; median observed depth & 0.23; 0.224 \\ | ||||
| 26 | +Multi-price weeks: single-store shops vs anti-bot storefronts & 0.047 vs 0.228 \\ | ||||
| 27 | +\bottomrule | ||||
| 28 | +\end{tabular} | ||||
| 29 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 30 | +\item Sample: grocery storefront listings observed in $\ge$ 8 of 11 complete weeks. Promotion state is inferred from the weekly modal price; regular prices are not historised in the source. Return episodes end with a price within $\pm$2\% of the pre-episode reference; reset episodes are regular-price changes that the 10\% rule also flags. | ||||
| 31 | +\end{tablenotes} | ||||
| 32 | +\end{threeparttable} | ||||
| 33 | +\end{table} | ||||
added
paper/tables/tab_qgpai.tex
+26 −0
@@ -0,0 +1,26 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{QGPAI components by circular week (Thursday start), 2026}\label{tab:qgpai} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{lrrrrrrrrr} | ||||
| 6 | +\toprule | ||||
| 7 | +Week & Comparable index & CI low & CI high & Promo share & Dispersion & Basket K=1 & Basket K=2 & Basket uncon. & Circular depth \\ | ||||
| 8 | +\midrule | ||||
| 9 | +13 Aug & 100.000 & 100.000 & 100.000 & & 0.155 & 34.166 & 31.429 & 29.523 & 0.224 \\ | ||||
| 10 | +20 Aug & 99.654 & 99.234 & 100.089 & 0.043 & 0.154 & 32.404 & 29.735 & 28.951 & 0.224 \\ | ||||
| 11 | +27 Aug & 98.670 & 98.188 & 99.187 & 0.029 & 0.154 & 34.867 & 31.067 & 29.568 & 0.246 \\ | ||||
| 12 | +03 Sep & 99.498 & 99.049 & 99.905 & 0.044 & 0.154 & 33.089 & 29.240 & 27.933 & 0.230 \\ | ||||
| 13 | +10 Sep & 100.065 & 99.544 & 100.498 & 0.042 & 0.154 & 31.877 & 29.613 & 28.120 & 0.237 \\ | ||||
| 14 | +17 Sep & 100.246 & 99.676 & 100.758 & 0.046 & 0.153 & 32.166 & 29.616 & 28.281 & 0.224 \\ | ||||
| 15 | +24 Sep & 100.367 & 99.784 & 100.857 & 0.052 & 0.153 & 32.322 & 28.410 & 27.176 & 0.224 \\ | ||||
| 16 | +01 Oct & 100.802 & 100.244 & 101.322 & 0.058 & 0.151 & 31.091 & 27.831 & 26.973 & 0.209 \\ | ||||
| 17 | +08 Oct & 100.813 & 100.207 & 101.325 & 0.068 & 0.151 & 31.165 & 27.637 & 26.687 & 0.224 \\ | ||||
| 18 | +15 Oct & 101.308 & 100.789 & 101.829 & 0.064 & 0.141 & 31.165 & 27.108 & 27.108 & 0.237 \\ | ||||
| 19 | +22 Oct & 100.688 & 100.106 & 101.212 & 0.054 & 0.151 & 31.165 & 27.907 & 27.907 & 0.229 \\ | ||||
| 20 | +\bottomrule | ||||
| 21 | +\end{tabular} | ||||
| 22 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 23 | +\item Component 5 (access and affordability) is not computed: no store geography or income data are joined. Comparable index: chained Jevons over matched groups (A+B), base 13 Aug = 100, bootstrap band over groups; Dispersion: median within-group max$-$min log gap; Basket: Model E (B = 5). Composites are illustrative only and reported in the replication package. | ||||
| 24 | +\end{tablenotes} | ||||
| 25 | +\end{threeparttable} | ||||
| 26 | +\end{table} | ||||
added
paper/tables/tab_robustness.tex
+39 −0
@@ -0,0 +1,39 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{Robustness of the dispersion statistic (H1)}\label{tab:robust} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{p{10cm}r} | ||||
| 6 | +\toprule | ||||
| 7 | +Specification & Median log gap \\ | ||||
| 8 | +\midrule | ||||
| 9 | +Baseline median gap (A+B, storefront) & 0.154 \\ | ||||
| 10 | +Balanced groups present in all 11 weeks (n groups 1300) & 0.163 \\ | ||||
| 11 | +Unit-price normalisation & 0.154 \\ | ||||
| 12 | +Trimmed 1/99\\% (mean, vs untrimmed mean) & 0.181 vs 0.189 \\ | ||||
| 13 | +Cross-company group-weeks only & 0.183 \\ | ||||
| 14 | +Non-promoted weeks only & 0.135 \\ | ||||
| 15 | +Group-weeks with a promoted listing & 0.237 \\ | ||||
| 16 | +Tiers A, price = p\_mode & 0.144 \\ | ||||
| 17 | +Tiers A, price = p\_min & 0.143 \\ | ||||
| 18 | +Tiers A, price = p\_max & 0.140 \\ | ||||
| 19 | +Tiers A, price = p\_twmean & 0.138 \\ | ||||
| 20 | +Tiers AB, price = p\_mode & 0.154 \\ | ||||
| 21 | +Tiers AB, price = p\_min & 0.154 \\ | ||||
| 22 | +Tiers AB, price = p\_max & 0.145 \\ | ||||
| 23 | +Tiers AB, price = p\_twmean & 0.145 \\ | ||||
| 24 | +Tiers ABC, price = p\_mode & 0.154 \\ | ||||
| 25 | +Tiers ABC, price = p\_min & 0.154 \\ | ||||
| 26 | +Tiers ABC, price = p\_max & 0.147 \\ | ||||
| 27 | +Tiers ABC, price = p\_twmean & 0.145 \\ | ||||
| 28 | +Tiers ABCD, price = p\_mode & 0.154 \\ | ||||
| 29 | +Tiers ABCD, price = p\_min & 0.154 \\ | ||||
| 30 | +Tiers ABCD, price = p\_max & 0.149 \\ | ||||
| 31 | +Tiers ABCD, price = p\_twmean & 0.146 \\ | ||||
| 32 | +Tiers AB, price = p\_mode+circulars & 0.154 \\ | ||||
| 33 | +\bottomrule | ||||
| 34 | +\end{tabular} | ||||
| 35 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 36 | +\item All rows use storefront listings in non-ambiguous group-weeks with $\ge$ 2 banners. | ||||
| 37 | +\end{tablenotes} | ||||
| 38 | +\end{threeparttable} | ||||
| 39 | +\end{table} | ||||
added
paper/tables/tab_spillovers.tex
+18 −0
@@ -0,0 +1,18 @@ | |||||
| 1 | +\begin{table}[htbp]\centering | ||||
| 2 | +\begin{threeparttable} | ||||
| 3 | +\caption{Descriptive lead--lag associations between a banner's weekly log price change and rivals' changes (Model C)}\label{tab:spillovers} | ||||
| 4 | +\small | ||||
| 5 | +\begin{tabular}{lrrrrrrr} | ||||
| 6 | +\toprule | ||||
| 7 | +Rival set & N & $k=0$ & $k=1$ & $k=2$ & $k=3$ & lead 1 & lead 2 \\ | ||||
| 8 | +\midrule | ||||
| 9 | +All rivals & 22\,213 & 0.037 (0.018) & 0.037 (0.012) & 0.015 (0.013) & 0.043 (0.015) & 0.009 (0.012) & 0.013 (0.015) \\ | ||||
| 10 | +Same-company rivals & 21\,192 & 0.028 (0.018) & 0.042 (0.012) & 0.011 (0.013) & 0.034 (0.015) & 0.013 (0.012) & 0.015 (0.014) \\ | ||||
| 11 | +Other-company rivals & 1\,784 & 0.081 (0.066) & -0.001 (0.048) & -0.057 (0.029) & 0.047 (0.060) & -0.108 (0.048) & -0.064 (0.049) \\ | ||||
| 12 | +\bottomrule | ||||
| 13 | +\end{tabular} | ||||
| 14 | +\begin{tablenotes}[flushleft]\footnotesize | ||||
| 15 | +\item Dependent variable: $\Delta\log p$ of banner $r$ for product $i$ in week $w$; regressors: mean $\Delta\log p$ of the other banners in the match group at $w-k$ (lags) and $w+k$ (leads, placebo). Banner and week fixed effects; s.e. clustered by match group. Contemporaneous terms absorb common shocks and are not causal; a non-zero lead coefficient indicates timing noise or anticipation and is reported as a failed placebo. | ||||
| 16 | +\end{tablenotes} | ||||
| 17 | +\end{threeparttable} | ||||
| 18 | +\end{table} | ||||
modified
research/decisions.md
+3 −0
@@ -6,3 +6,6 @@ | |||||
| 6 | | 2026-11-01T08:2x | Primary matching tiers A+B; C, D sensitivity | validation: C precision 0.45 (text) ; B 1.00 (text) / 0.913 (UPC) ; D 0.98 | | 6 | | 2026-11-01T08:2x | Primary matching tiers A+B; C, D sensitivity | validation: C precision 0.45 (text) ; B 1.00 (text) / 0.913 (UPC) ; D 0.98 | | ||
| 7 | | 2026-11-01T08:2x | Exclude size-less listings and multi-product circular offers from matching | giant chained groups (141 listings) in v0 | | 7 | | 2026-11-01T08:2x | Exclude size-less listings and multi-product circular offers from matching | giant chained groups (141 listings) in v0 | | ||
| 8 | | 2026-11-01T08:2x | **Preregistration frozen** | sha256 research/preregistration.md = 4dcb257ee9a1d335d51f639eece1d22719ff7031f9735ebf977409c8a7a0b96e ; config/basket.yaml = c9a0f56faf225ab242f7f623e1c948ea88c86b1fdc617da9a51986ef1ba5ad45 | | 8 | | 2026-11-01T08:2x | **Preregistration frozen** | sha256 research/preregistration.md = 4dcb257ee9a1d335d51f639eece1d22719ff7031f9735ebf977409c8a7a0b96e ; config/basket.yaml = c9a0f56faf225ab242f7f623e1c948ea88c86b1fdc617da9a51986ef1ba5ad45 | | ||
| 9 | +| 2026-11-01T08:4x | **A-001** Model E complete-block design (B widest-coverage banners × items carried by all B; B=5 primary, 4/6/7 sensitivity) | with the preregistered rule only 7 items were carried by every banner that covered ≥ 60 % of items; K=1 needs a complete block; estimand unchanged | | ||||
| 10 | +| 2026-11-01T08:4x | **A-002** Model E candidate screen [1/4, 4] × item median unit price | mis-parsed sizes (e.g. 'bananas' 22 $/kg at one banner) produced absurd item costs; data-cleaning rule, applied symmetrically | | ||||
| 11 | +| 2026-11-01T08:5x | **A-003** Model B: low-price episodes classified as `return` (post-episode price within ±2 % of pre-episode reference), `reset_down`, `reset_up`; H3 reported for `return` (temporary sales) and for all; event study anchored at episode start | the 10 % rule also flags permanent regular-price cuts; preregistered H3 concerns promotions (temporary) — classification implements that distinction, estimand unchanged | | ||||
modified
src/econometrics/model_b_promotions.py
+38 −19
@@ -46,12 +46,14 @@ def main(): | |||||
| 46 | volc.to_csv(P / "modelB_volatility_by_category.csv", index=False) | 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"] | 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()) | 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 | 49 | + # bootstrap over listings for the ratio (moment aggregation: resample listings with multinomial weights) | ||
| 50 | − rng = np.random.default_rng(SEED); uids = chg.uid.unique(); byuid = {u: g for u, g in chg.groupby("uid")} | 50 | + rng = np.random.default_rng(SEED) | ||
| 51 | − rat = [] | 51 | + mom = chg.groupby("uid").agg(n=("dlog_p", "size"), s1=("dlog_p", "sum"), q1=("dlog_p", lambda x: (x ** 2).sum()), s2=("dlog_ref", "sum"), q2=("dlog_ref", lambda x: (x ** 2).sum())) | ||
| 52 | − for b in range(300): | 52 | + M = mom.values; rat = [] | ||
| 53 | − samp = rng.choice(uids, size=len(uids), replace=True); d = pd.concat([byuid[u] for u in samp]) | 53 | + for b in range(999): | ||
| 54 | − rat.append(d.dlog_p.std() / d.dlog_ref.std()) | 54 | + w = rng.multinomial(len(M), np.full(len(M), 1 / len(M))) | ||
| 55 | + n = (w * M[:, 0]).sum(); s1 = (w * M[:, 1]).sum(); q1 = (w * M[:, 2]).sum(); s2 = (w * M[:, 3]).sum(); q2 = (w * M[:, 4]).sum() | ||||
| 56 | + sd1 = np.sqrt(q1 / n - (s1 / n) ** 2); sd2 = np.sqrt(q2 / n - (s2 / n) ** 2); rat.append(sd1 / sd2) | ||||
| 55 | out["ratio_ci95"] = [float(np.percentile(rat, 2.5)), float(np.percentile(rat, 97.5))] | 57 | 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 | 58 | # 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"] | 59 | single = ["giant_tiger", "pa", "epipresto", "nuvo", "boite_a_grains", "bocoboco", "aliments_merci", "akhavan", "mayrand", "aubut", "maturin", "tau", "loco", "avril"] | ||
@@ -82,24 +84,31 @@ def main(): | |||||
| 82 | out["share_duration_1w"] = float((unc.duration == 1).mean()); out["depth_median_unc"] = float(unc.depth.median()) | 84 | 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 | 85 | 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()) | 86 | 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 | 87 | + # Episode classification (A-003): 'return' if post-episode price within ±2 % of the pre-episode reference, | ||
| 88 | + # 'reset_down' if below, 'reset_up' if above — temporary sales vs regular-price changes that the 10 % rule also flags | ||||
| 89 | + unc = unc.copy(); x = unc.dlog_post_price_vs_pre_ref | ||||
| 90 | + unc["kind"] = np.where(x.abs() <= 0.02, "return", np.where(x < -0.02, "reset_down", "reset_up")); ep.loc[unc.index, "kind"] = unc.kind | ||||
| 91 | + ep.to_parquet(P / "modelB_episodes.parquet", index=False) | ||||
| 92 | + out["episode_kind_shares"] = unc.kind.value_counts(normalize=True).round(4).to_dict(); out["episode_kind_counts"] = unc.kind.value_counts().to_dict() | ||||
| 93 | + ret_ = unc[unc.kind == "return"] | ||||
| 94 | + out["return_episodes"] = dict(n=int(len(ret_)), duration_median=float(ret_.duration.median()), duration_mean=float(ret_.duration.mean()), depth_median=float(ret_.depth.median()), | ||||
| 95 | + share_duration_1w=float((ret_.duration == 1).mean())) | ||||
| 96 | + # H3 (temporary sales only): does the reference AFTER the sale differ from the reference BEFORE? (return class, by construction |Δ price| <= 2 %) | ||||
| 97 | + dr = ret_.dlog_ref.dropna(); out["H3_return_dlog_ref_mean"] = float(dr.mean()); out["H3_return_dlog_ref_se"] = float(dr.std() / np.sqrt(len(dr))); out["H3_return_n"] = int(len(dr)) | ||||
| 98 | + out["H3_return_share_up"] = float((dr > 1e-9).mean()); out["H3_return_share_down"] = float((dr < -1e-9).mean()) | ||||
| 99 | + # H3 (all low-price episodes): reference change — dominated by regular-price resets | ||||
| 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)) | 100 | 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()) | 101 | 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()) | 102 | 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 | 103 | + # Event study around episode end (weeks -4..+4), listing FE + week FE; pretrend joint test — vectorised merge | ||
| 90 | − ev = [] | 104 | + ks = pd.DataFrame({"k": range(-4, 5)}) | ||
| 91 | − for _, e in unc.iterrows(): | 105 | + evb = ret_[["uid", "start"]].merge(ks, how="cross"); evb["week"] = evb.start + pd.to_timedelta(evb.k * 7, unit="D") # anchored at the first promoted week of 'return' episodes | ||
| 92 | − g = sf[sf.uid == e.uid] | 106 | + ev = evb.merge(sf[["uid", "week", "log_p", "log_ref"]], on=["uid", "week"], how="inner")[["uid", "week", "k", "log_p", "log_ref"]] | ||
| 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): | 107 | if len(ev): | ||
| 99 | ev["k_f"] = ev.k.astype(int) | 108 | 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"}) | 109 | 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) | 110 | 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))] | 111 | + pre = [t for t in fit.coef().index if any(t.endswith(f"::{k}") or t.endswith(f"[T.{k}]") for k in (-4, -3, -2))] | ||
| 103 | try: | 112 | try: | ||
| 104 | w = fit.wald_test(R=None, q=None) if False else None | 113 | w = fit.wald_test(R=None, q=None) if False else None | ||
| 105 | except Exception: w = None | 114 | except Exception: w = None | ||
@@ -107,6 +116,15 @@ def main(): | |||||
| 107 | W = float(b @ np.linalg.solve(Vs, b)); pval = float(1 - stats.chi2.cdf(W, len(pre))) | 116 | 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) | 117 | 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"}) | 118 | fitr = pf.feols("log_ref ~ i(k_f, ref=-1) | uid + week", data=ev, vcov={"CRV1": "uid"}) | ||
| 119 | + # Falsification of the pre-sale elevation: single-store online shops (no store-mixture noise) vs anti-bot storefronts | ||||
| 120 | + single = ["giant_tiger", "pa", "epipresto", "nuvo", "boite_a_grains", "bocoboco", "aliments_merci", "akhavan", "mayrand", "aubut", "maturin", "tau", "loco", "avril"] | ||||
| 121 | + ev2 = ev.merge(sf[["uid", "source"]].drop_duplicates(), on="uid"); ev2["feedtype"] = np.where(ev2.source.isin(single), "single_store", "antibot") | ||||
| 122 | + rows = [] | ||||
| 123 | + for ft, d in ev2.groupby("feedtype"): | ||||
| 124 | + f2 = pf.feols("log_p ~ i(k_f, ref=-1) | uid + week", data=d, vcov={"CRV1": "uid"}) | ||||
| 125 | + for t in f2.coef().index: rows.append(dict(feedtype=ft, term=t, coef=float(f2.coef()[t]), se=float(f2.se()[t]), n=int(f2._N))) | ||||
| 126 | + pd.DataFrame(rows).to_csv(P / "modelB_event_study_by_feedtype.csv", index=False) | ||||
| 127 | + out["event_study_by_feedtype_n"] = ev2.groupby("feedtype").size().to_dict() | ||||
| 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) | 128 | 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 | 129 | # Placebo: random pseudo-episode ends for non-promoted listings | ||
| 112 | rng = np.random.default_rng(SEED); pl = [] | 130 | rng = np.random.default_rng(SEED); pl = [] | ||
@@ -126,8 +144,9 @@ def main(): | |||||
| 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) | 144 | 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)) | 145 | (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) | 146 | 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", | 147 | + log.info("H3: episodes %d (uncensored %d) kinds %s; return episodes: n %d, duration med %.1f, depth med %.3f, dlog_ref mean %.4f (se %.4f) up %.3f down %.3f; all: dlog_ref mean %.4f; placebo %.4f; pretrend p %.3f (n=%d)", | ||
| 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"))) | 148 | + out["n_episodes"], out["n_uncensored"], out["episode_kind_shares"], out["return_episodes"]["n"], out["return_episodes"]["duration_median"], out["return_episodes"]["depth_median"], | ||
| 149 | + out["H3_return_dlog_ref_mean"], out["H3_return_dlog_ref_se"], out["H3_return_share_up"], out["H3_return_share_down"], out["H3_dlog_ref_mean"], out["placebo_dlog_ref_mean"], out.get("pretrend_p", float("nan")), out.get("event_study_n", 0)) | ||||
| 131 | ledger("4", "model_b", f"ratio {out['ratio_all']:.2f}; episodes {out['n_episodes']}; H3 mean {out['H3_dlog_ref_mean']:.4f}") | 150 | ledger("4", "model_b", f"ratio {out['ratio_all']:.2f}; episodes {out['n_episodes']}; H3 mean {out['H3_dlog_ref_mean']:.4f}") | ||
| 132 | 151 | ||||
| 133 | if __name__ == "__main__": | 152 | if __name__ == "__main__": | ||
added
src/econometrics/robustness.py
+79 −0
@@ -0,0 +1,79 @@ | |||||
| 1 | +"""Phase 6 — robustness and falsification checks that are not already inside the model scripts. | ||||
| 2 | + | ||||
| 3 | +Runs: balanced common-product panel (H1); unit-price normalisation (H1 on log unit prices); outlier trimming (1/99 %); | ||||
| 4 | +category heterogeneity (H1); exclusion of same-company pairs; promotion-week exclusion (H1 gaps on non-promoted weeks only); | ||||
| 5 | +Holm correction over the preregistered primary tests; attrition table across stages. Results → data/processed/robustness_*.csv | ||||
| 6 | +and experiments/robustness_summary.json. | ||||
| 7 | +""" | ||||
| 8 | +import json, numpy as np, pandas as pd | ||||
| 9 | +from scipy import stats | ||||
| 10 | +from src.common import path, ROOT, get_logger, ledger, seed_everything, SEED | ||||
| 11 | +log = get_logger("robust"); P = path("processed"); E = ROOT / "experiments" | ||||
| 12 | + | ||||
| 13 | +def gaps(pm, col="log_p"): | ||||
| 14 | + return pm.groupby(["group_id", "week"])[col].agg(lambda s: s.max() - s.min()) | ||||
| 15 | + | ||||
| 16 | +def main(): | ||||
| 17 | + seed_everything(); out = {} | ||||
| 18 | + pm = pd.read_parquet(P / "panel_matched_AB.parquet"); pm = pm[(pm.channel == "online_storefront") & (~pm.ambiguous)] | ||||
| 19 | + pm = pm[pm.groupby(["group_id", "week"]).banner.transform("nunique") >= 2].copy() | ||||
| 20 | + base = gaps(pm); out["baseline_median_gap"] = float(base.median()) | ||||
| 21 | + # 1 balanced panel: groups with >= 2 banners in every one of the 11 weeks | ||||
| 22 | + nwk = pm.groupby("group_id").week.nunique(); bal_groups = nwk[nwk == pm.week.nunique()].index | ||||
| 23 | + bal = pm[pm.group_id.isin(bal_groups)]; gb = gaps(bal) | ||||
| 24 | + out["balanced"] = dict(groups=int(len(bal_groups)), median_gap=float(gb.median()), share_ge_5pct=float((gb >= .05).mean())) | ||||
| 25 | + # 2 unit-price normalisation | ||||
| 26 | + pu = pm[pm.log_unit_p.notna()]; gu = gaps(pu, "log_unit_p"); out["unit_price"] = dict(group_weeks=int(len(gu)), median_gap=float(gu.median()), share_ge_5pct=float((gu >= .05).mean())) | ||||
| 27 | + # 3 trimming | ||||
| 28 | + lo, hi = base.quantile(.01), base.quantile(.99); tr = base[(base >= lo) & (base <= hi)]; out["trimmed_1_99"] = dict(median_gap=float(tr.median()), mean_gap=float(tr.mean()), mean_untrimmed=float(base.mean())) | ||||
| 29 | + # 4 category heterogeneity (with Holm-adjusted test that median gap >= 5 % via sign test on gap-0.05) | ||||
| 30 | + rows = [] | ||||
| 31 | + for c, g in pm.groupby("category"): | ||||
| 32 | + gg = gaps(g) | ||||
| 33 | + if len(gg) < 50: continue | ||||
| 34 | + k = int((gg > .05).sum()); n = int(len(gg)); p = stats.binomtest(k, n, 0.5, alternative="greater").pvalue | ||||
| 35 | + rows.append(dict(category=c, group_weeks=n, median_gap=float(gg.median()), share_ge_5pct=float((gg >= .05).mean()), p_sign_test=p)) | ||||
| 36 | + cat = pd.DataFrame(rows).sort_values("median_gap", ascending=False) | ||||
| 37 | + m = len(cat); order = np.argsort(cat.p_sign_test.values); adj = np.empty(m) | ||||
| 38 | + for rank, i in enumerate(order): adj[i] = min(1.0, cat.p_sign_test.values[i] * (m - rank)) | ||||
| 39 | + cat["p_holm"] = np.maximum.accumulate(adj[order])[np.argsort(order)]; cat.to_csv(P / "robustness_category_gaps.csv", index=False) | ||||
| 40 | + # 5 same-company exclusion (cross-company only) and same-company only | ||||
| 41 | + xc = pm[pm.groupby(["group_id", "week"]).company.transform("nunique") >= 2]; gx = gaps(xc) | ||||
| 42 | + out["cross_company_only"] = dict(group_weeks=int(len(gx)), median_gap=float(gx.median())) | ||||
| 43 | + # 6 promotion exclusion: drop listing-weeks at <= 0.9 x trailing mode | ||||
| 44 | + def rolling_mode(s): | ||||
| 45 | + v = s.tolist(); o = [] | ||||
| 46 | + for i in range(len(v)): | ||||
| 47 | + vc = pd.Series(v[max(0, i - 7): i + 1]).value_counts(); o.append(max(vc[vc == vc.max()].index)) | ||||
| 48 | + return pd.Series(o, index=s.index) | ||||
| 49 | + pm = pm.sort_values(["uid", "week"]); pm["ref"] = pm.groupby("uid").p_mode.transform(rolling_mode); pm["promo"] = pm.p_mode <= 0.9 * pm.ref | ||||
| 50 | + np_ = pm[~pm.promo]; np_ = np_[np_.groupby(["group_id", "week"]).banner.transform("nunique") >= 2]; gn = gaps(np_) | ||||
| 51 | + out["non_promo_weeks_only"] = dict(group_weeks=int(len(gn)), median_gap=float(gn.median()), share_ge_5pct=float((gn >= .05).mean())) | ||||
| 52 | + po = pm[pm.groupby(["group_id", "week"]).promo.transform("max")]; gp = gaps(po); out["group_weeks_with_a_promo"] = dict(group_weeks=int(len(gp)), median_gap=float(gp.median())) | ||||
| 53 | + # 7 Holm over preregistered primary tests (one p per hypothesis family where a p-value exists) | ||||
| 54 | + A = json.load(open(E / "modelA_summary.json")); prim = {} | ||||
| 55 | + # H1: sign test that median gap > 5 % | ||||
| 56 | + k = int((base > .05).sum()); prim["H1_median_gap_gt_5pct"] = stats.binomtest(k, len(base), 0.5, alternative="greater").pvalue | ||||
| 57 | + prim["H1_AR1_gt_0.5"] = float(1 - stats.norm.cdf((A["ar1_rho"] - 0.5) / A["ar1_se"])) | ||||
| 58 | + if (E / "modelB_summary.json").exists(): | ||||
| 59 | + B = json.load(open(E / "modelB_summary.json")) | ||||
| 60 | + lo_, hi_ = B["ratio_ci95"]; prim["H2_ratio_ne_1"] = float(2 * min(np.mean(np.array([lo_, hi_]) <= 1), 0.5)) if not (lo_ <= 1 <= hi_) else 1.0 | ||||
| 61 | + prim["H2_ratio_ne_1"] = 0.0 if not (lo_ <= 1 <= hi_) else 1.0 # CI-based (bootstrap): reject at 5 % iff CI excludes 1 | ||||
| 62 | + prim["H3_return_episodes_dlog_ref_ne_0"] = float(2 * (1 - stats.norm.cdf(abs(B["H3_return_dlog_ref_mean"] / B["H3_return_dlog_ref_se"])))) | ||||
| 63 | + prim["H3_all_low_price_episodes_dlog_ref_ne_0"] = float(2 * (1 - stats.norm.cdf(abs(B["H3_dlog_ref_mean"] / B["H3_dlog_ref_se"])))) | ||||
| 64 | + if (E / "modelC_summary.json").exists(): | ||||
| 65 | + C = json.load(open(E / "modelC_summary.json")) | ||||
| 66 | + c = C["rival_all"]; z = c["coefs"]["rival_all_l1"] / c["ses"]["rival_all_l1"]; prim["H4_lag1_ne_0"] = float(2 * (1 - stats.norm.cdf(abs(z)))) | ||||
| 67 | + if (E / "modelD_summary.json").exists(): | ||||
| 68 | + D = json.load(open(E / "modelD_summary.json")); lo_, hi_ = D["bootstrap"]["gap_ci95"]; prim["H5_naive_vs_jevons_gap_ne_0"] = 0.0 if not (lo_ <= 0 <= hi_) else 1.0 | ||||
| 69 | + if (E / "modelE_summary.json").exists(): | ||||
| 70 | + Ee = json.load(open(E / "modelE_summary.json")); prim["H6_K2_captures_all"] = np.nan # descriptive share; no test | ||||
| 71 | + ps = pd.Series({k: v for k, v in prim.items() if not (isinstance(v, float) and np.isnan(v))}).sort_values() | ||||
| 72 | + holm = pd.DataFrame({"test": ps.index, "p": ps.values}); mm = len(holm); holm["p_holm"] = np.minimum(1, np.maximum.accumulate(holm.p.values * (mm - np.arange(mm)))) | ||||
| 73 | + holm.to_csv(P / "robustness_holm.csv", index=False); out["holm"] = holm.to_dict(orient="records") | ||||
| 74 | + (E / "robustness_summary.json").write_text(json.dumps(out, indent=2, default=float)) | ||||
| 75 | + log.info("%s", json.dumps({k: v for k, v in out.items() if k != "holm"}, indent=None, default=float)[:900]); log.info("\n%s", holm.to_string(index=False)) | ||||
| 76 | + ledger("6", "robustness", "robustness tables written") | ||||
| 77 | + | ||||
| 78 | +if __name__ == "__main__": | ||||
| 79 | + main() | ||||
modified
src/indices/basket_optimization.py
+45 −19
@@ -3,6 +3,12 @@ | |||||
| 3 | For each complete week: map each basket item to candidate listings per banner (category + name regex, parsable size, | 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 | 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. | 5 | MIP (PuLP/CBC). Travel costs are unobserved: an illustrative fixed per-banner cost grid is added as a labelled assumption. | ||
| 6 | + | ||||
| 7 | +Amendments logged in research/decisions.md (estimand unchanged): | ||||
| 8 | + A-001 complete-block design: the banner set is the B banners with the widest basket coverage and the item set is the | ||||
| 9 | + items all B banners carry (B = 5 primary; 4, 6, 7 sensitivity), so that K = 1 is feasible without imputation. | ||||
| 10 | + A-002 candidate screen: listings whose unit price is outside [1/4, 4] x the item's cross-banner median unit price are | ||||
| 11 | + dropped (mis-parsed sizes / wrong matches produce absurd unit prices). | ||||
| 6 | """ | 12 | """ | ||
| 7 | import json, re, numpy as np, pandas as pd, pulp, yaml | 13 | import json, re, numpy as np, pandas as pd, pulp, yaml | ||
| 8 | from src.common import path, ROOT, get_logger, ledger | 14 | from src.common import path, ROOT, get_logger, ledger | ||
@@ -15,6 +21,7 @@ def candidates(pw, items): | |||||
| 15 | if it.get("exclude_regex"): d = d[~d.name.fillna("").str.contains(it["exclude_regex"], case=False, regex=True)] | 21 | 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)] | 22 | 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 | 23 | if len(d) == 0: continue | ||
| 24 | + med = d.unit_p.median(); d = d[(d.unit_p >= med / 4) & (d.unit_p <= med * 4)] # A-002 | ||||
| 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)) | 25 | 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"]]) | 26 | 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) | 27 | return pd.concat(rows, ignore_index=True) | ||
@@ -45,25 +52,44 @@ def main(): | |||||
| 45 | cov = cand.groupby("item").agg(listings=("uid", "nunique"), banners=("banner", "nunique")).reset_index(); cov.to_csv(P / "modelE_item_coverage.csv", index=False) | 52 | 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()) | 53 | log.info("basket items with candidates: %d / %d ; median banners per item %.0f", cov.item.nunique(), len(items), cov.banners.median()) | ||
| 47 | results = [] | 54 | results = [] | ||
| 48 | − for w, d in cand.groupby("week"): | 55 | + for B in (4, 5, 6, 7): | ||
| 49 | − tbl = d.groupby(["item", "banner"]).item_cost.min().unstack() | 56 | + for w, d in cand.groupby("week"): | ||
| 50 | − # keep banners carrying >= 60 % of covered items, and items available at >= 2 banners (so that K=1 is feasible on a common set) | 57 | + tbl = d.groupby(["item", "banner"]).item_cost.min().unstack() | ||
| 51 | − tbl = tbl.loc[:, tbl.notna().mean() >= 0.6] | 58 | + top = tbl.notna().sum().sort_values(ascending=False).index[:B] # A-001: widest-coverage banners | ||
| 52 | − tbl = tbl[tbl.notna().sum(axis=1) >= 2] | 59 | + full = tbl[list(top)].dropna() # items carried by all B banners | ||
| 53 | − # for K=1 feasibility, restrict to items available at every retained banner | 60 | + if len(full) < 5: continue | ||
| 54 | − full = tbl.dropna() | 61 | + for K in (1, 2, 3, 4, 5, B): | ||
| 55 | − for K in (1, 2, 3, 4, 5, len(tbl.columns)): | 62 | + if K > B: continue | ||
| 56 | − tot, used, n = solve(full, K) | 63 | + 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))) | 64 | + results.append(dict(B=B, week=w, K=K if K < B else 99, cost=tot, banners=";".join(used), n_items=n, n_banners_avail=B)) | ||
| 58 | − # single-banner cost for each banner (full item set) | 65 | + for b in full.columns: | ||
| 59 | − for b in full.columns: | 66 | + results.append(dict(B=B, week=w, K=1, cost=float(full[b].sum()), banners=b, n_items=len(full), n_banners_avail=B, single=True)) | ||
| 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)) | 67 | + if B == 5: | ||
| 61 | − for fc in (5.0, 10.0, 20.0): | 68 | + for fc in (5.0, 10.0, 20.0): | ||
| 62 | − tot, used, n = solve(full, len(full.columns), fixed_cost=fc) | 69 | + tot, used, n = solve(full, B, 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)) | 70 | + results.append(dict(B=B, week=w, K=99, cost=tot, banners=";".join(used), n_items=n, n_banners_avail=B, fixed_cost=fc, n_used=len(used), cost_incl_travel=tot + fc * len(used))) | ||
| 71 | + # Fixed block over time (for QGPAI component 4): B=5 widest-coverage banners over the whole period and the items they | ||||
| 72 | + # all carry in EVERY week, so that the weekly basket is constant. | ||||
| 73 | + cov_all = cand.groupby(["banner", "week"]).item.nunique().groupby("banner").mean().sort_values(ascending=False); top5 = list(cov_all.index[:5]) | ||||
| 74 | + tbls = {w: d[d.banner.isin(top5)].groupby(["item", "banner"]).item_cost.min().unstack().reindex(columns=top5) for w, d in cand.groupby("week")} | ||||
| 75 | + common_items = set.intersection(*[set(t.dropna().index) for t in tbls.values()]) | ||||
| 76 | + fixed = [] | ||||
| 77 | + for w, t in tbls.items(): | ||||
| 78 | + full = t.loc[sorted(common_items)] | ||||
| 79 | + for K in (1, 2, 5): | ||||
| 80 | + tot, used, n = solve(full, K); fixed.append(dict(week=w, K=K if K < 5 else 99, cost=tot, banners=";".join(used), n_items=n)) | ||||
| 81 | + fx = pd.DataFrame(fixed); fx.to_csv(P / "modelE_fixed_block.csv", index=False) | ||||
| 82 | + out["fixed_block"] = dict(banners=top5, n_items=len(common_items), mean_cost_K1=float(fx[fx.K == 1].cost.mean()), mean_cost_K2=float(fx[fx.K == 2].cost.mean()), mean_cost_unc=float(fx[fx.K == 99].cost.mean())) | ||||
| 83 | + log.info("fixed block: banners %s, %d items constant over weeks; K1 %.2f K2 %.2f unc %.2f", top5, len(common_items), out["fixed_block"]["mean_cost_K1"], out["fixed_block"]["mean_cost_K2"], out["fixed_block"]["mean_cost_unc"]) | ||||
| 64 | r = pd.DataFrame(results); r.to_csv(P / "modelE_results.csv", index=False) | 84 | 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()] | 85 | + r5 = r[r.B == 5] | ||
| 66 | − front = base.groupby("K").cost.mean(); singles = r[r.get("single") == True].groupby("week").cost.agg(["min", "max", "mean"]) | 86 | + base = r5[(r5.single.isna()) & (r5.fixed_cost.isna())] | ||
| 87 | + front = base.groupby("K").cost.mean(); singles = r5[r5.single == True].groupby("week").cost.agg(["min", "max", "mean"]) | ||||
| 88 | + sens = [] | ||||
| 89 | + for B, rb in r.groupby("B"): | ||||
| 90 | + bb = rb[(rb.single.isna()) & (rb.fixed_cost.isna())].groupby("K").cost.mean() | ||||
| 91 | + sens.append(dict(B=B, n_items=int(rb.n_items.iloc[0]), cost_K1=bb.get(1), cost_K2=bb.get(2), cost_unconstrained=bb.get(99), savings_share=(bb.get(1) - bb.get(99)) / bb.get(1), share_at_K2=(bb.get(1) - bb.get(2)) / (bb.get(1) - bb.get(99)))) | ||||
| 92 | + pd.DataFrame(sens).to_csv(P / "modelE_block_sensitivity.csv", index=False); out["block_sensitivity"] = sens | ||||
| 67 | out["items_in_common_set_mean"] = float(base.n_items.mean()); out["banners_available_mean"] = float(base.n_banners_avail.mean()) | 93 | 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()} | 94 | 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) | 95 | c1, c2, cinf = front.get(1), front.get(2), front.get(99) | ||
@@ -72,7 +98,7 @@ def main(): | |||||
| 72 | out["single_banner_cost_spread_mean"] = float((singles["max"] - singles["min"]).mean()); out["single_banner_mean_cost"] = float(singles["mean"].mean()) | 98 | 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()) | 99 | out["most_expensive_vs_cheapest_single_share"] = float(((singles["max"] - singles["min"]) / singles["min"]).mean()) | ||
| 74 | if "fixed_cost" in r: | 100 | 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() | 101 | + tc = r5[r5.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") | 102 | 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)) | 103 | (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%%)", | 104 | 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%%)", | ||
added
src/indices/qgpai.py
+82 −0
@@ -0,0 +1,82 @@ | |||||
| 1 | +"""Phase 5 — Quebec Grocery Price and Affordability Index (QGPAI): components published separately (preregistration §3). | ||||
| 2 | + | ||||
| 3 | +1 Comparable Price Index — chained Jevons over matched groups (A+B), storefront, base week 2026-08-13 = 100 | ||||
| 4 | +2 Promotion Intensity — share of listing-weeks promoted (inferred, storefront) and observed circular depth | ||||
| 5 | +3 Retail Price Dispersion — weekly median within-group max–min log gap | ||||
| 6 | +4 Feasible Basket Cost — Model E weekly cost at K=1 (best single banner) and K=2 on a block that is constant over weeks | ||||
| 7 | + (5 widest-coverage banners × items they all carry in every week) | ||||
| 8 | +5 Access & Affordability — NOT computed (no geography / income join); documented placeholder | ||||
| 9 | +Illustrative composite: equal-weight z-scores of components 1–4 (and a 0.5/0.5 alternative on 1 and 4) — labelled as an | ||||
| 10 | +illustration, with listing-level bootstrap bands for components 1 and 3. | ||||
| 11 | +""" | ||||
| 12 | +import json, numpy as np, pandas as pd | ||||
| 13 | +from src.common import path, ROOT, get_logger, ledger, seed_everything, SEED | ||||
| 14 | +log = get_logger("qgpai"); P = path("processed"); E = ROOT / "experiments" | ||||
| 15 | + | ||||
| 16 | +def jevons(df, col="p_mode"): | ||||
| 17 | + weeks = sorted(df.week.unique()); piv = df.pivot_table(index="uid", columns="week", values=col); idx = [100.0] | ||||
| 18 | + for a, b in zip(weeks, weeks[1:]): | ||||
| 19 | + both = piv[[a, b]].dropna(); idx.append(idx[-1] * np.exp((np.log(both[b]) - np.log(both[a])).mean())) | ||||
| 20 | + return pd.Series(idx, index=weeks) | ||||
| 21 | + | ||||
| 22 | +def main(): | ||||
| 23 | + seed_everything(); rng = np.random.default_rng(SEED); out = {} | ||||
| 24 | + pm = pd.read_parquet(P / "panel_matched_AB.parquet"); pm = pm[(pm.channel == "online_storefront") & (~pm.ambiguous)] | ||||
| 25 | + pm = pm[pm.groupby(["group_id", "week"]).banner.transform("nunique") >= 2] | ||||
| 26 | + # 1 comparable price index (listings inside matched groups) with bootstrap over groups | ||||
| 27 | + c1 = jevons(pm); groups = pm.group_id.unique(); boots = [] | ||||
| 28 | + for b in range(299): | ||||
| 29 | + s = rng.choice(groups, size=len(groups), replace=True); d = pd.concat([pm[pm.group_id == g] for g in s[:0]]) if False else None | ||||
| 30 | + w = pd.Series(rng.multinomial(len(groups), np.full(len(groups), 1 / len(groups))), index=groups) | ||||
| 31 | + # weighted Jevons: weight listings by their group's multiplicity | ||||
| 32 | + piv = pm.pivot_table(index="uid", columns="week", values="p_mode"); gw = pm.drop_duplicates("uid").set_index("uid").group_id.map(w).reindex(piv.index).values | ||||
| 33 | + weeks = list(piv.columns); idx = [100.0] | ||||
| 34 | + for a, c in zip(weeks, weeks[1:]): | ||||
| 35 | + both = piv[[a, c]].dropna(); ww = gw[piv.index.get_indexer(both.index)] | ||||
| 36 | + idx.append(idx[-1] * np.exp(np.average(np.log(both[c]) - np.log(both[a]), weights=ww))) | ||||
| 37 | + boots.append(idx) | ||||
| 38 | + boots = np.array(boots); c1_lo, c1_hi = np.percentile(boots, 2.5, axis=0), np.percentile(boots, 97.5, axis=0) | ||||
| 39 | + # 3 dispersion with bootstrap over groups | ||||
| 40 | + gaps = pm.groupby(["group_id", "week"]).log_p.agg(lambda s: s.max() - s.min()).reset_index() | ||||
| 41 | + c3 = gaps.groupby("week").log_p.median(); b3 = [] | ||||
| 42 | + for b in range(299): | ||||
| 43 | + w = pd.Series(rng.multinomial(len(groups), np.full(len(groups), 1 / len(groups))), index=groups) | ||||
| 44 | + g2 = gaps.loc[gaps.index.repeat(gaps.group_id.map(w).values)]; b3.append(g2.groupby("week").log_p.median().reindex(c3.index).values) | ||||
| 45 | + b3 = np.array(b3); c3_lo, c3_hi = np.percentile(b3, 2.5, axis=0), np.percentile(b3, 97.5, axis=0) | ||||
| 46 | + # 2 promotion intensity (storefront inferred) — recompute weekly from panel_weekly with the same 8-week-mode rule | ||||
| 47 | + pw = pd.read_parquet(P / "panel_weekly.parquet"); sf = pw[pw.channel == "online_storefront"].sort_values(["uid", "week"]).copy() | ||||
| 48 | + sf = sf[sf.groupby("uid").week.transform("nunique") >= 8] | ||||
| 49 | + def rolling_mode(s): | ||||
| 50 | + v = s.tolist(); o = [] | ||||
| 51 | + for i in range(len(v)): | ||||
| 52 | + vc = pd.Series(v[max(0, i - 7): i + 1]).value_counts(); o.append(max(vc[vc == vc.max()].index)) | ||||
| 53 | + return pd.Series(o, index=s.index) | ||||
| 54 | + sf["ref"] = sf.groupby("uid").p_mode.transform(rolling_mode); sf["promo"] = sf.p_mode <= 0.9 * sf.ref; sf["depth"] = np.where(sf.promo, np.log(sf.ref / sf.p_mode), np.nan) | ||||
| 55 | + c2 = sf.groupby("week").agg(promo_share=("promo", "mean"), depth_mean=("depth", "mean"), depth_sd=("depth", "std")) | ||||
| 56 | + c2.iloc[0, :] = np.nan # first week has no trailing history: promotion state undefined | ||||
| 57 | + # circular observed depth by week (items valid that week) | ||||
| 58 | + prod = pd.read_parquet(P / "products.parquet"); ret = pd.read_parquet(P / "retailers.parquet") | ||||
| 59 | + circ = prod.merge(ret[["feed_id", "channel", "is_grocery"]], left_on="source", right_on="feed_id"); circ = circ[circ.channel.str.startswith("circular") & circ.is_grocery & circ.regular_price.notna() & circ.price.notna()] | ||||
| 60 | + circ["vf"] = pd.to_datetime(circ.valid_from, utc=True, errors="coerce").dt.tz_convert("America/Toronto").dt.tz_localize(None).dt.normalize() | ||||
| 61 | + circ["depth"] = np.log(circ.regular_price / circ.price); cd = circ.groupby("vf").depth.agg(["median", "size"]); cd.index.name = "week" | ||||
| 62 | + # 4 basket cost | ||||
| 63 | + e = pd.read_csv(P / "modelE_fixed_block.csv"); e["week"] = pd.to_datetime(e.week) # constant item set over weeks | ||||
| 64 | + c4 = e.pivot_table(index="week", columns="K", values="cost").rename(columns={1: "basket_K1", 2: "basket_K2", 99: "basket_unconstrained"}) | ||||
| 65 | + # assemble | ||||
| 66 | + weeks = pd.to_datetime(pd.Series(c1.index)) | ||||
| 67 | + q = pd.DataFrame({"week": weeks, "comparable_price_index": c1.values, "cpi_lo": c1_lo, "cpi_hi": c1_hi, "dispersion_median_gap": c3.reindex(c1.index).values, "disp_lo": c3_lo, "disp_hi": c3_hi}) | ||||
| 68 | + q = q.merge(c2.reset_index().assign(week=lambda d: pd.to_datetime(d.week)), on="week", how="left").merge(c4.reset_index(), on="week", how="left") | ||||
| 69 | + q = q.merge(cd.reset_index().rename(columns={"median": "circular_depth_median", "size": "circular_items"}).assign(week=lambda d: pd.to_datetime(d.week)), on="week", how="left") | ||||
| 70 | + q["access_affordability"] = np.nan | ||||
| 71 | + # illustrative composites (z-scores over the 11 weeks) | ||||
| 72 | + z = lambda s: (s - s.mean()) / s.std(ddof=0) if s.std(ddof=0) > 0 else s * 0 | ||||
| 73 | + q["composite_equal_illustrative"] = (z(q.comparable_price_index) + z(q.promo_share) * -1 + z(q.dispersion_median_gap) + z(q.basket_K1)) / 4 | ||||
| 74 | + q["composite_alt_illustrative"] = 0.5 * z(q.comparable_price_index) + 0.5 * z(q.basket_K1) | ||||
| 75 | + q.to_csv(P / "qgpai_components.csv", index=False) | ||||
| 76 | + out["components"] = q.round(4).to_dict(orient="records"); out["note"] = "component 5 not computed; composites are illustrations with prespecified weights, not estimates" | ||||
| 77 | + (E / "qgpai_summary.json").write_text(json.dumps(out, indent=2, default=str)) | ||||
| 78 | + log.info("\n%s", q[["week", "comparable_price_index", "dispersion_median_gap", "promo_share", "basket_K1", "basket_K2", "basket_unconstrained", "circular_depth_median"]].round(3).to_string(index=False)) | ||||
| 79 | + ledger("5", "qgpai", "components written") | ||||
| 80 | + | ||||
| 81 | +if __name__ == "__main__": | ||||
| 82 | + main() | ||||
added
src/reports/make_figures.py
+153 −0
@@ -0,0 +1,153 @@ | |||||
| 1 | +"""Phase 7 — publication figures (vector PDF) → paper/figures/. Palette and mark rules follow the dataviz reference | ||||
| 2 | +(categorical slots in fixed order, one hue sequential ramp, thin marks, direct labels, recessive grid, no dual axes).""" | ||||
| 3 | +import json, numpy as np, pandas as pd, matplotlib | ||||
| 4 | +matplotlib.use("Agg") | ||||
| 5 | +import matplotlib.pyplot as plt | ||||
| 6 | +from matplotlib.colors import LinearSegmentedColormap | ||||
| 7 | +from src.common import ROOT, path, get_logger | ||||
| 8 | +log = get_logger("figures"); P = path("processed"); F = ROOT / "paper" / "figures"; E = ROOT / "experiments"; F.mkdir(exist_ok=True) | ||||
| 9 | +C = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"] | ||||
| 10 | +SEQ = LinearSegmentedColormap.from_list("seq", ["#cde2fb", "#86b6ef", "#3987e5", "#256abf", "#184f95", "#0d366b"]) | ||||
| 11 | +TXT, TXT2, GRID = "#0b0b0b", "#52514e", "#e6e5e1" | ||||
| 12 | +plt.rcParams.update({"font.size": 9, "axes.edgecolor": TXT2, "axes.labelcolor": TXT, "xtick.color": TXT2, "ytick.color": TXT2, "axes.spines.top": False, "axes.spines.right": False, | ||||
| 13 | + "axes.grid": True, "grid.color": GRID, "grid.linewidth": 0.6, "axes.axisbelow": True, "legend.frameon": False, "pdf.fonttype": 42}) | ||||
| 14 | + | ||||
| 15 | +def save(fig, name): | ||||
| 16 | + fig.savefig(F / f"{name}.pdf", bbox_inches="tight"); plt.close(fig); log.info("wrote %s.pdf", name) | ||||
| 17 | + | ||||
| 18 | +def fig_coverage(): | ||||
| 19 | + wk = pd.read_csv(P / "coverage_by_week.csv", parse_dates=["week"]); feed = pd.read_csv(P / "coverage_by_feed.csv"); feed = feed[feed.active > 0] | ||||
| 20 | + fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.0), gridspec_kw={"width_ratios": [1.3, 1]}) | ||||
| 21 | + ax = axes[0]; ax.plot(wk.week, wk.online, color=C[0], lw=2, marker="o", ms=4); ax.plot(wk.week, wk.circular, color=C[1], lw=2, marker="o", ms=4) | ||||
| 22 | + ax.text(wk.week.iloc[-1], wk.online.iloc[-1], " storefront", color=TXT, va="center"); ax.text(wk.week.iloc[-1], wk.circular.iloc[-1], " circular", color=TXT, va="center") | ||||
| 23 | + ax.set_ylabel("Listings observed in week"); ax.set_title("A. Weekly coverage by channel", loc="left", color=TXT); ax.tick_params(axis="x", rotation=30); ax.set_ylim(0) | ||||
| 24 | + ax = axes[1]; comp = feed.groupby("company").active.sum().sort_values(ascending=True).tail(10) | ||||
| 25 | + ax.barh(comp.index, comp.values, color=C[0], height=0.6); ax.set_title("B. Active listings by company (top 10)", loc="left", color=TXT); ax.set_xlabel("Active listings, 1 Nov 2026") | ||||
| 26 | + for i, v in enumerate(comp.values): ax.text(v, i, f" {v:,}", va="center", color=TXT2, fontsize=8) | ||||
| 27 | + ax.grid(axis="y", visible=False) | ||||
| 28 | + save(fig, "fig_coverage") | ||||
| 29 | + | ||||
| 30 | +def fig_attrition(): | ||||
| 31 | + att = pd.read_csv(P / "attrition_listings.csv"); v = pd.read_csv(P / "match_validation_summary.csv") | ||||
| 32 | + fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.0), gridspec_kw={"width_ratios": [1.4, 1]}) | ||||
| 33 | + ax = axes[0]; y = np.arange(len(att))[::-1]; ax.barh(y, att.listings, color=[C[0]] * (len(att) - 1) + [C[2]], height=0.6) | ||||
| 34 | + labels = ["All listings", "excl. SAQ", "excl. pharmacies", "excl. non-food", "with a price", "excl. unrealistic", "parsable size", "matched (A–C)"] | ||||
| 35 | + ax.set_yticks(y); ax.set_yticklabels(labels); ax.set_xlabel("Listings"); ax.set_title("A. Attrition flow", loc="left", color=TXT); ax.grid(axis="y", visible=False) | ||||
| 36 | + for yi, n in zip(y, att.listings): ax.text(n, yi, f" {n:,}", va="center", color=TXT2, fontsize=8) | ||||
| 37 | + ax = axes[1]; x = np.arange(4); ax.bar(x - 0.2, v.precision_strict, 0.38, color=C[0], label="Text review (240 pairs)"); ax.bar(x + 0.2, v.upc_precision.fillna(0), 0.38, color=C[1], label="UPC ground truth") | ||||
| 38 | + ax.errorbar(x - 0.2, v.precision_strict, yerr=[v.precision_strict - v.ci_lo, v.ci_hi - v.precision_strict], fmt="none", ecolor=TXT2, lw=1) | ||||
| 39 | + ax.set_xticks(x); ax.set_xticklabels([f"Tier {t}" for t in v.tier]); ax.set_ylim(0, 1.05); ax.set_ylabel("Precision"); ax.set_title("B. Matching precision by tier", loc="left", color=TXT); ax.legend(loc="lower left", fontsize=7); ax.grid(axis="x", visible=False) | ||||
| 40 | + save(fig, "fig_matching") | ||||
| 41 | + | ||||
| 42 | +def fig_gaps(): | ||||
| 43 | + st = pd.read_csv(P / "modelA_groupweek_stats.csv"); fig, axes = plt.subplots(1, 2, figsize=(7.2, 3.0)) | ||||
| 44 | + ax = axes[0]; bins = np.linspace(0, 1.0, 41) | ||||
| 45 | + ax.hist(st[st.same_company].gap.clip(upper=1), bins=bins, color=C[0], alpha=0.85, density=True, label="same company"); ax.hist(st[~st.same_company].gap.clip(upper=1), bins=bins, color=C[1], alpha=0.6, density=True, label="different companies") | ||||
| 46 | + ax.set_xlabel("Within-product max–min log price gap (group-week)"); ax.set_ylabel("Density"); ax.legend(); ax.set_title("A. Distribution of cross-banner gaps", loc="left", color=TXT); ax.grid(axis="x", visible=False) | ||||
| 47 | + fe = pd.read_csv(P / "modelA_banner_effects.csv").sort_values("coef"); fe = fe[fe.n_listing_weeks >= 50] | ||||
| 48 | + ax = axes[1]; y = np.arange(len(fe)); ax.errorbar(fe.coef, y, xerr=1.96 * fe.se, fmt="o", color=C[0], ecolor=TXT2, ms=4, lw=1); ax.axvline(0, color=TXT2, lw=0.8) | ||||
| 49 | + ax.set_yticks(y); ax.set_yticklabels(fe.banner); ax.set_xlabel("Banner effect on log price (ref. Metro), 95% CI"); ax.set_title("B. Relative price levels", loc="left", color=TXT); ax.grid(axis="y", visible=False) | ||||
| 50 | + save(fig, "fig_dispersion") | ||||
| 51 | + | ||||
| 52 | +def fig_heatmap(): | ||||
| 53 | + pt = pd.read_csv(P / "modelA_banner_pairs.csv"); pt = pt[pt.n >= 30] | ||||
| 54 | + banners = sorted(set(pt.banner_r) | set(pt.banner_s)); M = pd.DataFrame(np.nan, index=banners, columns=banners) | ||||
| 55 | + for _, r in pt.iterrows(): M.at[r.banner_r, r.banner_s] = r.median_gap; M.at[r.banner_s, r.banner_r] = -r.median_gap | ||||
| 56 | + fig, ax = plt.subplots(figsize=(5.6, 4.8)); cm = LinearSegmentedColormap.from_list("div", ["#2a78d6", "#f0efec", "#e34948"]) | ||||
| 57 | + im = ax.imshow(M.values, cmap=cm, vmin=-0.3, vmax=0.3); ax.set_xticks(range(len(banners))); ax.set_xticklabels(banners, rotation=60, ha="right"); ax.set_yticks(range(len(banners))); ax.set_yticklabels(banners); ax.grid(False) | ||||
| 58 | + for i in range(len(banners)): | ||||
| 59 | + for j in range(len(banners)): | ||||
| 60 | + if not np.isnan(M.values[i, j]): ax.text(j, i, f"{M.values[i, j]:+.2f}", ha="center", va="center", fontsize=6.5, color=TXT) | ||||
| 61 | + cb = fig.colorbar(im, ax=ax, fraction=0.04); cb.set_label("Median log price gap, row banner minus column banner") | ||||
| 62 | + ax.set_title("Pairwise median gaps on identical products (pairs with ≥ 30 group-weeks)", loc="left", color=TXT, fontsize=9) | ||||
| 63 | + save(fig, "fig_heatmap") | ||||
| 64 | + | ||||
| 65 | +def fig_promotions(): | ||||
| 66 | + es = pd.read_csv(P / "modelB_event_study.csv"); es["k"] = es.term.str.extract(r"(-?\d+)$").astype(int); es = pd.concat([es, pd.DataFrame([{"term": "ref", "coef": 0, "se": 0, "k": -1}])]).sort_values("k") | ||||
| 67 | + ep = pd.read_parquet(P / "modelB_episodes.parquet"); tm = pd.read_csv(P / "modelB_transition_matrix.csv", index_col=0) | ||||
| 68 | + fig, axes = plt.subplots(1, 3, figsize=(7.6, 2.8), gridspec_kw={"width_ratios": [1.3, 1, 0.8]}) | ||||
| 69 | + ax = axes[0]; ax.fill_between(es.k, es.coef - 1.96 * es.se, es.coef + 1.96 * es.se, color=C[0], alpha=0.18, lw=0); ax.plot(es.k, es.coef, color=C[0], lw=2, marker="o", ms=4); ax.axhline(0, color=TXT2, lw=0.8); ax.axvline(-0.5, color=TXT2, lw=0.8, ls=":") | ||||
| 70 | + ax.set_xlabel("Weeks relative to first promoted week"); ax.set_ylabel("Log price vs week −1"); ax.set_title("A. Price path around temporary sales", loc="left", color=TXT) | ||||
| 71 | + ax = axes[1]; unc = ep[~ep.censored]; ax.hist(unc[unc.kind == "return"].depth, bins=np.linspace(0, 1.2, 31), color=C[0], alpha=0.85, label="return (temporary)"); ax.hist(unc[unc.kind == "reset_down"].depth, bins=np.linspace(0, 1.2, 31), color=C[1], alpha=0.6, label="reset down") | ||||
| 72 | + ax.set_xlabel("Episode depth, log(reference / price)"); ax.set_ylabel("Episodes"); ax.legend(fontsize=7); ax.set_title("B. Depth by episode kind", loc="left", color=TXT); ax.grid(axis="x", visible=False) | ||||
| 73 | + ax = axes[2]; im = ax.imshow(tm.values, cmap=SEQ, vmin=0, vmax=1); ax.set_xticks([0, 1]); ax.set_xticklabels(["regular", "promo"]); ax.set_yticks([0, 1]); ax.set_yticklabels(["regular", "promo"]); ax.set_xlabel("state in week w"); ax.set_ylabel("state in week w−1"); ax.grid(False) | ||||
| 74 | + for i in range(2): | ||||
| 75 | + for j in range(2): ax.text(j, i, f"{tm.values[i, j]:.2f}", ha="center", va="center", color="white" if tm.values[i, j] > 0.5 else TXT) | ||||
| 76 | + ax.set_title("C. Weekly transitions", loc="left", color=TXT) | ||||
| 77 | + save(fig, "fig_promotions") | ||||
| 78 | + | ||||
| 79 | +def fig_spillovers(): | ||||
| 80 | + d = pd.read_csv(P / "modelC_distributed_lags.csv"); fig, ax = plt.subplots(figsize=(5.2, 2.8)) | ||||
| 81 | + order = {"rival_all": 0, "rival_same": 1, "rival_cross": 2}; lab = {"rival_all": "all rivals", "rival_same": "same company", "rival_cross": "other company"} | ||||
| 82 | + xs = ["_f2", "_f1", "", "_l1", "_l2", "_l3"]; xl = ["lead 2", "lead 1", "k=0", "lag 1", "lag 2", "lag 3"] | ||||
| 83 | + for col, g in d.groupby("rival"): | ||||
| 84 | + i = order[col]; vals = [g[g.term == col + s].coef.iloc[0] for s in xs]; ses = [g[g.term == col + s].se.iloc[0] for s in xs] | ||||
| 85 | + ax.errorbar(np.arange(6) + (i - 1) * 0.22, vals, yerr=[1.96 * v for v in ses], fmt="o", color=C[i], ms=4, lw=1, label=lab[col]) | ||||
| 86 | + ax.axhline(0, color=TXT2, lw=0.8); ax.axvspan(-0.5, 1.5, color=GRID, alpha=0.5, lw=0); ax.set_xticks(range(6)); ax.set_xticklabels(xl); ax.set_ylabel("Response of own Δlog p"); ax.legend(fontsize=7, ncol=3, loc="upper left") | ||||
| 87 | + ax.set_title("Lead–lag association with rivals' price changes (shaded = placebo leads)", loc="left", color=TXT, fontsize=9); ax.grid(axis="x", visible=False) | ||||
| 88 | + save(fig, "fig_spillovers") | ||||
| 89 | + | ||||
| 90 | +def fig_indices(): | ||||
| 91 | + d = pd.read_csv(P / "modelD_indices.csv", parse_dates=["week"]); s = d[d["sample"] == "storefront_posted"] | ||||
| 92 | + fig, ax = plt.subplots(figsize=(5.6, 3.0)) | ||||
| 93 | + ax.plot(s.week, s.index_mean, color=C[1], lw=2, marker="o", ms=3); ax.plot(s.week, s["index"], color=C[0], lw=2, marker="o", ms=3); ax.plot(s.week, s.jevons, color=C[2], lw=2, marker="o", ms=3, ls="--"); ax.plot(s.week, s.carli, color=C[3], lw=1.5, marker="o", ms=3, ls=":") | ||||
| 94 | + for col, name in [("index_mean", "naive mean of posted prices"), ("index", "chained Jevons (matched model)"), ("jevons", "fixed-basket Jevons"), ("carli", "fixed-basket Carli")]: | ||||
| 95 | + ax.text(s.week.iloc[-1], s[col].iloc[-1], " " + name, va="center", fontsize=7.5, color=TXT) | ||||
| 96 | + ax.set_ylabel("Index (13 Aug 2026 = 100)"); ax.tick_params(axis="x", rotation=30); ax.set_title("Unweighted price indices, storefront listings", loc="left", color=TXT, fontsize=9) | ||||
| 97 | + save(fig, "fig_indices") | ||||
| 98 | + | ||||
| 99 | +def fig_qgpai(): | ||||
| 100 | + q = pd.read_csv(P / "qgpai_components.csv", parse_dates=["week"]); fig, axes = plt.subplots(2, 2, figsize=(7.2, 4.8)); axes = axes.ravel() | ||||
| 101 | + ax = axes[0]; ax.fill_between(q.week, q.cpi_lo, q.cpi_hi, color=C[0], alpha=0.18, lw=0); ax.plot(q.week, q.comparable_price_index, color=C[0], lw=2, marker="o", ms=3); ax.set_title("1. Comparable price index (matched groups)", loc="left", color=TXT); ax.set_ylabel("13 Aug = 100") | ||||
| 102 | + ax = axes[1]; ax.plot(q.week, q.promo_share, color=C[1], lw=2, marker="o", ms=3); ax.plot(q.week, q.circular_depth_median, color=C[3], lw=2, marker="s", ms=3) | ||||
| 103 | + ax.text(q.week.iloc[-1], q.promo_share.iloc[-1], " share promoted\n (storefront, inferred)", fontsize=7, va="center", color=TXT); ax.text(q.week.iloc[-1], q.circular_depth_median.iloc[-1], " circular depth\n (observed, median log)", fontsize=7, va="center", color=TXT) | ||||
| 104 | + ax.set_title("2. Promotion intensity", loc="left", color=TXT); ax.set_ylim(0) | ||||
| 105 | + ax = axes[2]; ax.fill_between(q.week, q.disp_lo, q.disp_hi, color=C[2], alpha=0.18, lw=0); ax.plot(q.week, q.dispersion_median_gap, color=C[2], lw=2, marker="o", ms=3); ax.set_title("3. Retail price dispersion (median log gap)", loc="left", color=TXT); ax.set_ylim(0) | ||||
| 106 | + ax = axes[3] | ||||
| 107 | + for col, name, c in [("basket_K1", "best single banner", C[0]), ("basket_K2", "two banners", C[1]), ("basket_unconstrained", "unconstrained", C[2])]: | ||||
| 108 | + ax.plot(q.week, q[col], color=c, lw=2, marker="o", ms=3); ax.text(q.week.iloc[-1], q[col].iloc[-1], " " + name, fontsize=7, va="center", color=TXT) | ||||
| 109 | + ax.set_title("4. Feasible basket cost (CAD, fixed block)", loc="left", color=TXT); ax.set_ylim(0) | ||||
| 110 | + for ax in axes: ax.tick_params(axis="x", rotation=30) | ||||
| 111 | + fig.suptitle("QGPAI components (component 5, access & affordability, not computable)", x=0.01, ha="left", color=TXT2, fontsize=9) | ||||
| 112 | + save(fig, "fig_qgpai") | ||||
| 113 | + | ||||
| 114 | +def fig_basket(): | ||||
| 115 | + r = pd.read_csv(P / "modelE_results.csv"); r5 = r[(r.B == 5) & r.single.isna() & r.fixed_cost.isna()]; fr = r5.groupby("K").cost.mean(); ks = [1, 2, 3, 4, 5]; costs = [fr.get(k, fr.get(99)) if k < 5 else fr.get(99) for k in ks] | ||||
| 116 | + tc = pd.read_csv(P / "modelE_travel_sensitivity.csv") | ||||
| 117 | + fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.8)) | ||||
| 118 | + ax = axes[0]; ax.plot(ks, costs, color=C[0], lw=2, marker="o", ms=5); ax.set_xlabel("Maximum number of banners visited (K)"); ax.set_ylabel("Weekly basket cost (CAD, mean of 11 weeks)"); ax.set_xticks(ks) | ||||
| 119 | + for k, c in zip(ks, costs): ax.text(k, c, f" {c:.1f}", fontsize=7.5, color=TXT2, va="bottom") | ||||
| 120 | + ax.set_title("A. Savings frontier (B = 5, 15 items)", loc="left", color=TXT) | ||||
| 121 | + ax = axes[1]; ax.bar(tc.fixed_cost.astype(str), tc.cost_incl, color=C[1], width=0.55); ax.bar(tc.fixed_cost.astype(str), tc.cost_goods, color=C[0], width=0.55) | ||||
| 122 | + for i, (g, t, n) in enumerate(zip(tc.cost_goods, tc.cost_incl, tc.n_used)): ax.text(i, t, f" {n:.1f} banners", ha="center", va="bottom", fontsize=7.5, color=TXT2) | ||||
| 123 | + ax.set_xlabel("Illustrative fixed cost per banner visited (CAD)"); ax.set_ylabel("Goods (blue) + travel (orange)"); ax.set_title("B. Travel-cost sensitivity (assumption-based)", loc="left", color=TXT); ax.grid(axis="x", visible=False) | ||||
| 124 | + save(fig, "fig_basket") | ||||
| 125 | + | ||||
| 126 | +def fig_forecast(): | ||||
| 127 | + r = pd.read_csv(P / "modelF_rolling_results.csv"); fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.8)) | ||||
| 128 | + ax = axes[0]; d = r[r.target == "log_price"] | ||||
| 129 | + for i, (m, g) in enumerate(d.groupby("model")): ax.plot(g.origin_t, g.mae, color=C[i % 8], lw=1.8, marker="o", ms=3, label=m) | ||||
| 130 | + ax.set_xlabel("Forecast origin (week index)"); ax.set_ylabel("MAE of next-week log price"); ax.legend(fontsize=6.5, ncol=2); ax.set_title("A. Price forecasts", loc="left", color=TXT) | ||||
| 131 | + ax = axes[1]; d = r[r.target == "promotion"] | ||||
| 132 | + for i, (m, g) in enumerate(d.groupby("model")): ax.plot(g.origin_t, g.auc, color=C[i % 8], lw=1.8, marker="o", ms=3, label=m) | ||||
| 133 | + ax.set_xlabel("Forecast origin (week index)"); ax.set_ylabel("AUC, next-week promotion"); ax.set_ylim(0.4, 1); ax.legend(fontsize=6.5, ncol=2, loc="lower right"); ax.set_title("B. Promotion forecasts", loc="left", color=TXT) | ||||
| 134 | + save(fig, "fig_forecast") | ||||
| 135 | + | ||||
| 136 | +def fig_store_mixture(): | ||||
| 137 | + d = pd.read_parquet(P / "offers_daily.parquet", columns=["uid", "day", "p_min", "p_max", "p_mode", "n_distinct"]); x = d[d.uid == "metro:068400662600"].sort_values("day") | ||||
| 138 | + pl = pd.read_parquet(path("interim") / "raw_price_log.parquet"); e = pl[pl.uid == "metro:068400662600"].copy(); e["ts"] = pd.to_datetime(e.ts, unit="s", utc=True).dt.tz_convert("America/Toronto") | ||||
| 139 | + sm = pd.read_csv(P / "modelA_within_banner_mixture.csv").sort_values("share_multi", ascending=True).tail(12) | ||||
| 140 | + fig, axes = plt.subplots(1, 2, figsize=(7.4, 2.9), gridspec_kw={"width_ratios": [1.4, 1]}) | ||||
| 141 | + ax = axes[0]; ax.step(e.ts, e.price, where="post", color=C[0], lw=1.2); ax.scatter(e.ts, e.price, color=C[0], s=8, zorder=3) | ||||
| 142 | + ax.set_ylabel("Posted price (CAD)"); ax.set_title("A. One listing (Metro, mayonnaise 890 mL): every logged price change", loc="left", color=TXT, fontsize=8.5); ax.tick_params(axis="x", rotation=30) | ||||
| 143 | + ax = axes[1]; ax.barh(sm.banner, sm.share_multi, color=C[0], height=0.6); ax.set_xlabel("Share of listing-weeks with > 1 price value"); ax.set_title("B. Multi-price weeks by banner", loc="left", color=TXT); ax.grid(axis="y", visible=False) | ||||
| 144 | + for i, (v, r_) in enumerate(zip(sm.share_multi, sm.median_log_range_multi)): ax.text(v, i, f" {v:.2f} (range {r_:.2f})", va="center", fontsize=7, color=TXT2) | ||||
| 145 | + save(fig, "fig_store_mixture") | ||||
| 146 | + | ||||
| 147 | +def main(): | ||||
| 148 | + for f in [fig_coverage, fig_attrition, fig_gaps, fig_heatmap, fig_promotions, fig_spillovers, fig_indices, fig_qgpai, fig_basket, fig_forecast, fig_store_mixture]: | ||||
| 149 | + try: f() | ||||
| 150 | + except Exception as ex: log.error("%s failed: %s", f.__name__, ex) | ||||
| 151 | + | ||||
| 152 | +if __name__ == "__main__": | ||||
| 153 | + main() | ||||
added
src/reports/make_tables.py
+121 −0
@@ -0,0 +1,121 @@ | |||||
| 1 | +"""Phase 7 — LaTeX tables (booktabs, threeparttable notes) from processed outputs → paper/tables/*.tex.""" | ||||
| 2 | +import json, numpy as np, pandas as pd | ||||
| 3 | +from src.common import ROOT, path, SETTINGS, get_logger | ||||
| 4 | +log = get_logger("tables"); P = path("processed"); T = ROOT / "paper" / "tables"; E = ROOT / "experiments"; T.mkdir(exist_ok=True) | ||||
| 5 | + | ||||
| 6 | +def tex_escape(s): | ||||
| 7 | + return str(s).replace("&", "\\&").replace("%", "\\%").replace("_", "\\_").replace("#", "\\#") | ||||
| 8 | + | ||||
| 9 | +def table(df, fname, caption, label, note, colfmt=None, float_fmt="{:.3f}", index=False): | ||||
| 10 | + df = df.copy() | ||||
| 11 | + for c in df.columns: | ||||
| 12 | + if df[c].dtype.kind == "f": df[c] = df[c].map(lambda x: "" if pd.isna(x) else float_fmt.format(x)) | ||||
| 13 | + elif df[c].dtype.kind in "iu": df[c] = df[c].map(lambda x: f"{x:,}".replace(",", "\\,")) | ||||
| 14 | + else: df[c] = df[c].map(tex_escape) | ||||
| 15 | + cols = [tex_escape(c) for c in df.columns]; colfmt = colfmt or ("l" + "r" * (len(cols) - 1)) | ||||
| 16 | + body = " \\\\\n".join(" & ".join(map(str, r)) for r in df.values) | ||||
| 17 | + tex = f"""\\begin{{table}}[htbp]\\centering | ||||
| 18 | +\\begin{{threeparttable}} | ||||
| 19 | +\\caption{{{caption}}}\\label{{{label}}} | ||||
| 20 | +\\small | ||||
| 21 | +\\begin{{tabular}}{{{colfmt}}} | ||||
| 22 | +\\toprule | ||||
| 23 | +{' & '.join(cols)} \\\\ | ||||
| 24 | +\\midrule | ||||
| 25 | +{body} \\\\ | ||||
| 26 | +\\bottomrule | ||||
| 27 | +\\end{{tabular}} | ||||
| 28 | +\\begin{{tablenotes}}[flushleft]\\footnotesize | ||||
| 29 | +\\item {note} | ||||
| 30 | +\\end{{tablenotes}} | ||||
| 31 | +\\end{{threeparttable}} | ||||
| 32 | +\\end{{table}} | ||||
| 33 | +""" | ||||
| 34 | + (T / fname).write_text(tex); log.info("wrote %s", fname) | ||||
| 35 | + | ||||
| 36 | +def main(): | ||||
| 37 | + A = json.load(open(E / "modelA_summary.json")); B = json.load(open(E / "modelB_summary.json")); C = json.load(open(E / "modelC_summary.json")) | ||||
| 38 | + D = json.load(open(E / "modelD_summary.json")); Ee = json.load(open(E / "modelE_summary.json")); R = json.load(open(E / "robustness_summary.json")) | ||||
| 39 | + # T1 coverage by feed (top 20 + rest) | ||||
| 40 | + f = pd.read_csv(P / "coverage_by_feed.csv"); f = f[f.active > 0] | ||||
| 41 | + t1 = f[["company", "banner", "channel", "listings", "active", "median_days_obs", "price_events", "unit_price_cov", "brand_cov"]].head(24).copy() | ||||
| 42 | + t1.columns = ["Company", "Banner", "Channel", "Listings", "Active", "Median days", "Price events", "Unit price", "Brand"] | ||||
| 43 | + table(t1, "tab_coverage_feeds.tex", "Coverage of the 24 largest source feeds (snapshot of 1 November 2026)", "tab:coverage", | ||||
| 44 | + "Listings = all uids ever observed in the feed (active and deactivated); Active = listed on 1 Nov 2026; Median days = median observation span; Price events = rows in the price log; Unit price / Brand = share of active listings with a parsable package size (re-parsed) / a brand field. Channel: online storefront vs weekly circular (Flipp, postal code H2X 1Y4). Full table of 54 feeds in the replication package.", float_fmt="{:.2f}") | ||||
| 45 | + # T2 attrition | ||||
| 46 | + att = pd.read_csv(P / "attrition_listings.csv"); att.columns = ["Step", "Listings", "Share of initial"] | ||||
| 47 | + table(att, "tab_attrition.tex", "Listing-level sample attrition", "tab:attrition", "Each row applies the stated filter to the previous row. Matching tiers A--C shown; the primary analysis uses A+B.", colfmt="p{9cm}rr") | ||||
| 48 | + # T3 matching validation | ||||
| 49 | + v = pd.read_csv(P / "match_validation_summary.csv") | ||||
| 50 | + t3 = v[["tier", "n", "same", "different", "unsure", "precision_strict", "ci_lo", "ci_hi", "upc_precision", "upc_recall"]].copy() | ||||
| 51 | + t3.columns = ["Tier", "n", "Same", "Different", "Unsure", "Precision", "CI low", "CI high", "UPC precision", "UPC recall"] | ||||
| 52 | + table(t3, "tab_match_validation.tex", "Matching validation: stratified sample (60 pairs per tier) and UPC ground truth", "tab:validation", | ||||
| 53 | + "Precision = share labelled SAME (Wilson 95\\% CI); labels by Claude (claude-opus-5-5) as second annotator with full operator review of every pair; PI adjudication pending. UPC columns: precision/recall of the tier's text rule on all Metro\\,$\\times$\\,Super~C candidate pairs within brand\\,$\\times$\\,package blocks, where the shared GS1 code is the truth (B: identical token sets; C: token-sort ratio $\\ge 0.90$). Tier D has no UPC benchmark.") | ||||
| 54 | + # T4 dispersion summary | ||||
| 55 | + rows = [] | ||||
| 56 | + for k, lab in [("gap_all", "All group-weeks"), ("gap_same_company", "Same company (two banners)"), ("gap_cross_company", "Different companies")]: | ||||
| 57 | + g = A[k]; rows.append(dict(Sample=lab, **{"Group-weeks": g["n"], "Median": g["median"], "P25": g["p25"], "P75": g["p75"], "Mean": g["mean"], "Share $\\ge$ 5\\%": g["share_ge_5pct"], "Share $\\ge$ 10\\%": g["share_ge_10pct"]})) | ||||
| 58 | + t4 = pd.DataFrame(rows) | ||||
| 59 | + table(t4, "tab_dispersion.tex", "Within-product cross-banner log price gaps (max$-$min), tiers A+B, storefront channel", "tab:dispersion", | ||||
| 60 | + f"Weekly posted price = duration-weighted modal price. Residual s.d. of the product-week/banner two-way fixed-effects model: {A['resid_sd']:.3f} (N = {A['n_obs_fe']:,}). AR(1) of banner-pair gaps: {A['ar1_rho']:.3f} (s.e. {A['ar1_se']:.3f}); placebo with banner labels permuted within group-week: {A['placebo_ar1_mean']:.3f} (s.d. {A['placebo_ar1_sd']:.3f}). Share of banner pairs ($\\ge$ 4 weeks) whose sign never flips: {A['share_pairs_sign_stable_ge4w']:.2f}.") | ||||
| 61 | + # T5 banner effects | ||||
| 62 | + fe = pd.read_csv(P / "modelA_banner_effects.csv").sort_values("coef") | ||||
| 63 | + t5 = fe[["banner", "coef", "se", "se_twoway", "n_listing_weeks"]].copy(); t5.columns = ["Banner", "Coefficient", "s.e. (group)", "s.e. (group+banner)", "Listing-weeks"] | ||||
| 64 | + table(t5, "tab_banner_effects.tex", f"Banner fixed effects on log posted price within product-week (reference: {A['banner_fe_reference']})", "tab:bannerfe", | ||||
| 65 | + "Model A: $\\log p_{irw} = \\alpha_{iw} + \\gamma_r + \\varepsilon_{irw}$ on matched groups (tiers A+B), storefront channel, non-ambiguous group-weeks. Standard errors clustered by match group, and two-way by group and banner. Coefficients are relative posted-price levels for the same product in the same week; banners with few listing-weeks are imprecisely estimated.", float_fmt="{:.3f}") | ||||
| 66 | + # T6 promotions | ||||
| 67 | + rows = [("Listing-weeks promoted (inferred, $p \\le 0.9\\times$ trailing 8-week mode)", B["promo_share_listing_weeks"]), ("Median depth (log ref/p), promoted weeks", B["promo_depth_median"]), | ||||
| 68 | + ("Agreement with current on-sale flag (last week)", B["promo_vs_on_sale_now_agreement"]), ("S.d. weekly $\\Delta\\log p$, raw", B["sd_raw_all"]), ("S.d. weekly $\\Delta\\log$ reference", B["sd_ref_all"]), | ||||
| 69 | + ("Ratio raw / reference (bootstrap 95\\% CI)", f"{B['ratio_all']:.2f} [{B['ratio_ci95'][0]:.2f}, {B['ratio_ci95'][1]:.2f}]"), ("Share of weekly price changes that are promotion transitions", B["share_weekly_changes_promo_transition"]), | ||||
| 70 | + ("Episodes (uncensored)", f"{B['n_episodes']:,} ({B['n_uncensored']:,})"), ("Episode kinds: return / reset down / reset up", " / ".join(f"{B['episode_kind_shares'].get(k, 0):.2f}" for k in ("return", "reset_down", "reset_up"))), | ||||
| 71 | + ("Return episodes: median duration (weeks), share 1 week", f"{B['return_episodes']['duration_median']:.0f}, {B['return_episodes']['share_duration_1w']:.2f}"), ("Return episodes: median depth", B["return_episodes"]["depth_median"]), | ||||
| 72 | + ("Median recurrence interval (weeks), share of listings with $\\ge$ 2 episodes", f"{B['recurrence_median_weeks']:.0f}, {B['share_listings_with_2plus_episodes']:.2f}"), | ||||
| 73 | + ("H3 (return episodes): mean $\\Delta\\log$ reference after vs before (s.e.)", f"{B['H3_return_dlog_ref_mean']:.4f} ({B['H3_return_dlog_ref_se']:.4f}), n = {B['H3_return_n']:,}"), | ||||
| 74 | + ("H3 (all low-price episodes): mean $\\Delta\\log$ reference (s.e.)", f"{B['H3_dlog_ref_mean']:.4f} ({B['H3_dlog_ref_se']:.4f})"), ("Placebo (random weeks, non-promoted listings)", f"{B['placebo_dlog_ref_mean']:.4f} ({B['placebo_dlog_ref_se']:.4f})"), | ||||
| 75 | + ("Event-study pretrend test (weeks $-4..-2$), $p$", f"{B.get('pretrend_p', float('nan')):.3f}"), ("Circular items with observed regular price; median observed depth", f"{B['circular_share_with_regular']:.2f}; {B['circular_depth_median']:.3f}"), | ||||
| 76 | + ("Multi-price weeks: single-store shops vs anti-bot storefronts", f"{B['multi_price_week_share_single_store']:.3f} vs {B['multi_price_week_share_antibot']:.3f}")] | ||||
| 77 | + t6 = pd.DataFrame(rows, columns=["Statistic", "Value"]); t6["Value"] = t6.Value.map(lambda v: f"{v:.3f}" if isinstance(v, float) else v) | ||||
| 78 | + table(t6, "tab_promotions.tex", "Promotion dynamics in storefront feeds (Model B)", "tab:promotions", | ||||
| 79 | + "Sample: grocery storefront listings observed in $\\ge$ 8 of 11 complete weeks. Promotion state is inferred from the weekly modal price; regular prices are not historised in the source. Return episodes end with a price within $\\pm$2\\% of the pre-episode reference; reset episodes are regular-price changes that the 10\\% rule also flags.", colfmt="p{10.5cm}r") | ||||
| 80 | + # T7 spillovers | ||||
| 81 | + rows = [] | ||||
| 82 | + for col, lab in [("rival_all", "All rivals"), ("rival_same", "Same-company rivals"), ("rival_cross", "Other-company rivals")]: | ||||
| 83 | + if col not in C: continue | ||||
| 84 | + c = C[col]; r = {"Rival set": lab, "N": c["n"]} | ||||
| 85 | + for t, nm in [(col, "$k=0$"), (col + "_l1", "$k=1$"), (col + "_l2", "$k=2$"), (col + "_l3", "$k=3$"), (col + "_f1", "lead 1"), (col + "_f2", "lead 2")]: | ||||
| 86 | + r[nm] = f"{c['coefs'][t]:.3f} ({c['ses'][t]:.3f})" | ||||
| 87 | + rows.append(r) | ||||
| 88 | + table(pd.DataFrame(rows), "tab_spillovers.tex", "Descriptive lead--lag associations between a banner's weekly log price change and rivals' changes (Model C)", "tab:spillovers", | ||||
| 89 | + "Dependent variable: $\\Delta\\log p$ of banner $r$ for product $i$ in week $w$; regressors: mean $\\Delta\\log p$ of the other banners in the match group at $w-k$ (lags) and $w+k$ (leads, placebo). Banner and week fixed effects; s.e. clustered by match group. Contemporaneous terms absorb common shocks and are not causal; a non-zero lead coefficient indicates timing noise or anticipation and is reported as a failed placebo.", colfmt="lr" + "r" * 6) | ||||
| 90 | + # T8 indices | ||||
| 91 | + rows = [] | ||||
| 92 | + for k, lab in [("storefront_posted", "Storefront, posted price"), ("storefront_unit", "Storefront, unit price"), ("all_channels_posted", "All channels, posted price")]: | ||||
| 93 | + s = D[k]; rows.append({"Sample": lab, "Naive mean": s["naive_mean_change"], "Naive median": s["naive_median_change"], "Chained Jevons": s["jevons_chained_change"], "Fixed Jevons": s["fixed_jevons_change"], "Fixed Dutot": s["fixed_dutot_change"], "Fixed Carli": s["fixed_carli_change"], "Balanced n": s["n_balanced"]}) | ||||
| 94 | + table(pd.DataFrame(rows), "tab_indices.tex", "Cumulative price change over 11 weeks (13 Aug $\\to$ 22 Oct 2026) under alternative unweighted index formulas", "tab:indices", | ||||
| 95 | + f"No expenditure weights exist: none of these is a consumer price index. Bootstrap (999 listing resamples) 95\\% CI for the storefront chained Jevons: [{D['bootstrap']['jevons_ci95'][0]:.3f}, {D['bootstrap']['jevons_ci95'][1]:.3f}]; naive-mean minus Jevons gap: {D['bootstrap']['gap_mean']:.3f} [{D['bootstrap']['gap_ci95'][0]:.3f}, {D['bootstrap']['gap_ci95'][1]:.3f}]. Statistics Canada CPI, Quebec, food purchased from stores: Aug$\\to$Sep 2026 {D['statcan']['cpi_change_aug_sep']:+.3f} (our chained Jevons, monthly means, {D['statcan']['ours_jevons_aug_sep']:+.3f}); 12-month CPI change to Sep 2026 {D['statcan']['cpi_yoy_sep']:+.3f}.") | ||||
| 96 | + # T9 basket | ||||
| 97 | + bs = pd.read_csv(P / "modelE_block_sensitivity.csv"); bs.columns = ["Banners (B)", "Items", "Cost K=1", "Cost K=2", "Cost unconstrained", "Savings share", "Share captured at K=2"] | ||||
| 98 | + table(bs, "tab_basket.tex", "Fixed-basket cost under banner-count constraints (Model E, weekly means over 11 weeks, CAD)", "tab:basket", | ||||
| 99 | + f"Complete-block design (amendment A-001): the $B$ widest-coverage storefront banners and the basket items all of them carry; item cost = cheapest unit price $\\times$ reference quantity. K = 1: best single banner. Travel costs are unobserved; with an illustrative fixed cost per banner of 5 / 10 / 20 CAD the optimum uses {Ee['travel_cost_sensitivity'][0]['n_used']:.2f} / {Ee['travel_cost_sensitivity'][1]['n_used']:.2f} / {Ee['travel_cost_sensitivity'][2]['n_used']:.2f} banners on average. Spread between the cheapest and most expensive single banner for the same items: {100*Ee['most_expensive_vs_cheapest_single_share']:.0f}\\% of the cheapest.", float_fmt="{:.2f}") | ||||
| 100 | + # T10 forecasts | ||||
| 101 | + fs = pd.read_csv(P / "modelF_summary.csv"); fp = fs[fs.target == "log_price"][["model", "origins", "n", "mae", "rmse"]]; fq = fs[fs.target == "promotion"][["model", "origins", "n", "brier", "auc"]] | ||||
| 102 | + fp.columns = ["Model", "Origins", "N", "MAE", "RMSE"]; fq.columns = ["Model", "Origins", "N", "Brier", "AUC"] | ||||
| 103 | + table(fp, "tab_forecast_price.tex", "Rolling-origin forecasts of next-week log posted price (storefront listings)", "tab:fprice", "Strict time-based holdouts: train on weeks $\\le t-1$, test on week $t$, origins $t = 6,\\dots,9$; features are lags only. Lower is better.", float_fmt="{:.4f}") | ||||
| 104 | + table(fq, "tab_forecast_promo.tex", "Rolling-origin forecasts of next-week promotion state", "tab:fpromo", "Promotion = modal price $\\le 0.9\\times$ trailing 8-week mode. Brier: lower is better; AUC: higher is better. Base rate $\\approx$ 5\\%.", float_fmt="{:.4f}") | ||||
| 105 | + # T11 robustness | ||||
| 106 | + rows = [("Baseline median gap (A+B, storefront)", R["baseline_median_gap"]), ("Balanced groups present in all 11 weeks (n groups %d)" % R["balanced"]["groups"], R["balanced"]["median_gap"]), ("Unit-price normalisation", R["unit_price"]["median_gap"]), | ||||
| 107 | + ("Trimmed 1/99\\% (mean, vs untrimmed mean)", f"{R['trimmed_1_99']['mean_gap']:.3f} vs {R['trimmed_1_99']['mean_untrimmed']:.3f}"), ("Cross-company group-weeks only", R["cross_company_only"]["median_gap"]), ("Non-promoted weeks only", R["non_promo_weeks_only"]["median_gap"]), ("Group-weeks with a promoted listing", R["group_weeks_with_a_promo"]["median_gap"])] | ||||
| 108 | + sens = pd.read_csv(P / "modelA_sensitivity.csv") | ||||
| 109 | + for _, r in sens.iterrows(): rows.append((f"Tiers {r.tiers}, price = {r.price_def}", r.median_gap)) | ||||
| 110 | + t11 = pd.DataFrame(rows, columns=["Specification", "Median log gap"]); t11["Median log gap"] = t11["Median log gap"].map(lambda v: f"{v:.3f}" if isinstance(v, float) else v) | ||||
| 111 | + table(t11, "tab_robustness.tex", "Robustness of the dispersion statistic (H1)", "tab:robust", "All rows use storefront listings in non-ambiguous group-weeks with $\\ge$ 2 banners.", colfmt="p{10cm}r") | ||||
| 112 | + holm = pd.read_csv(P / "robustness_holm.csv"); holm.columns = ["Preregistered test", "p", "Holm-adjusted p"] | ||||
| 113 | + table(holm, "tab_holm.tex", "Preregistered primary tests with Holm correction", "tab:holm", "CI-based entries take p = 0 when the bootstrap 95\\% CI excludes the null value and 1 otherwise (conservative coding).", float_fmt="{:.4f}", colfmt="p{8cm}rr") | ||||
| 114 | + # T12 QGPAI | ||||
| 115 | + q = pd.read_csv(P / "qgpai_components.csv"); tq = q[["week", "comparable_price_index", "cpi_lo", "cpi_hi", "promo_share", "dispersion_median_gap", "basket_K1", "basket_K2", "basket_unconstrained", "circular_depth_median"]].copy() | ||||
| 116 | + tq["week"] = pd.to_datetime(tq.week).dt.strftime("%d %b"); tq.columns = ["Week", "Comparable index", "CI low", "CI high", "Promo share", "Dispersion", "Basket K=1", "Basket K=2", "Basket uncon.", "Circular depth"] | ||||
| 117 | + table(tq, "tab_qgpai.tex", "QGPAI components by circular week (Thursday start), 2026", "tab:qgpai", "Component 5 (access and affordability) is not computed: no store geography or income data are joined. Comparable index: chained Jevons over matched groups (A+B), base 13 Aug = 100, bootstrap band over groups; Dispersion: median within-group max$-$min log gap; Basket: Model E (B = 5). Composites are illustrative only and reported in the replication package.", float_fmt="{:.3f}") | ||||
| 118 | + log.info("tables done") | ||||
| 119 | + | ||||
| 120 | +if __name__ == "__main__": | ||||
| 121 | + main() | ||||
| 122 | |||||