SPB Git

spb/wp37_uqo

Public

phase4-7: model B episode classification + event study by feed type, robustness, QGPAI, tables and figures

Simon-Pierre Boucher committed (Nov 1, 2026) parent 81852e1 Browse files

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,
3 − "banners_available_mean": 7.7272727272727275,
2 + "fixed_block": {
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 55 "frontier_mean_cost_by_K": {
5 − "1": 23.12194937122785,
6 − "2": 20.888101262563136,
7 − "3": 20.204123130937315,
8 − "4": 20.054139457868775,
9 − "5": 20.034210164939484,
10 − "99": 20.028755619484937
56 + "1": 67.28423159055245,
57 + "2": 58.50114263955248,
58 + "3": 57.245563977839794,
59 + "4": 56.98363046574241,
60 + "99": 56.91885773846968
11 61 },
12 − "savings_unconstrained_vs_best_single": 3.0931937517429127,
13 − "savings_share_unconstrained": 0.1337773775939487,
14 − "share_of_unconstrained_savings_at_K2": 0.7221817603265276,
15 − "single_banner_cost_spread_mean": 39.69805844961497,
16 − "single_banner_mean_cost": 32.88295457993973,
17 − "most_expensive_vs_cheapest_single_share": 1.856238474704022,
62 + "savings_unconstrained_vs_best_single": 10.365373852082769,
63 + "savings_share_unconstrained": 0.1540535368696727,
64 + "share_of_unconstrained_savings_at_K2": 0.8473489790467271,
65 + "single_banner_cost_spread_mean": 60.83196506195359,
66 + "single_banner_mean_cost": 87.58897378738362,
67 + "most_expensive_vs_cheapest_single_share": 0.9131745576696246,
18 68 "travel_cost_sensitivity": [
19 69 {
20 70 "fixed_cost": 5.0,
21 − "n_used": 1.0,
22 − "cost_goods": 18.12194937122785,
23 − "cost_incl": 23.12194937122785
71 + "n_used": 1.8181818181818181,
72 + "cost_goods": 59.313548870407836,
73 + "cost_incl": 68.40445796131691
24 74 },
25 75 {
26 76 "fixed_cost": 10.0,
27 − "n_used": 1.0,
28 − "cost_goods": 13.12194937122785,
29 − "cost_incl": 23.12194937122785
77 + "n_used": 1.2727272727272727,
78 + "cost_goods": 63.538701559764334,
79 + "cost_incl": 76.26597428703707
30 80 },
31 81 {
32 82 "fixed_cost": 20.0,
33 83 "n_used": 1.0,
34 − "cost_goods": 3.121949371227848,
35 − "cost_incl": 23.12194937122785
84 + "cost_goods": 67.28423159055245,
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 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 7 | 2026-11-01T08:2x | Exclude size-less listings and multi-product circular offers from matching | giant chained groups (141 listings) in v0 |
8 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 46 volc.to_csv(P / "modelB_volatility_by_category.csv", index=False)
47 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 48 out["share_weekly_changes_promo_transition"] = float(chg[chg.is_change].promo_transition.mean()); out["freq_weekly_change"] = float(chg.is_change.mean())
49 − # bootstrap over listings for the ratio
50 − rng = np.random.default_rng(SEED); uids = chg.uid.unique(); byuid = {u: g for u, g in chg.groupby("uid")}
51 − rat = []
52 − for b in range(300):
53 − samp = rng.choice(uids, size=len(uids), replace=True); d = pd.concat([byuid[u] for u in samp])
54 − rat.append(d.dlog_p.std() / d.dlog_ref.std())
49 + # bootstrap over listings for the ratio (moment aggregation: resample listings with multinomial weights)
50 + rng = np.random.default_rng(SEED)
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 + M = mom.values; rat = []
53 + for b in range(999):
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 57 out["ratio_ci95"] = [float(np.percentile(rat, 2.5)), float(np.percentile(rat, 97.5))]
56 58 # Falsification: single-store online shops (Shopify/WooCommerce) vs anti-bot storefronts — share of multi-price weeks
57 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 84 out["share_duration_1w"] = float((unc.duration == 1).mean()); out["depth_median_unc"] = float(unc.depth.median())
83 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 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 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 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 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
90 − ev = []
91 − for _, e in unc.iterrows():
92 − g = sf[sf.uid == e.uid]
93 − for k in range(-4, 5):
94 − wk = e.end + pd.Timedelta(weeks=k)
95 − r = g[g.week == wk]
96 − if len(r): ev.append(dict(uid=e.uid, week=wk, k=k, log_p=float(r.log_p.iloc[0]), log_ref=float(r.log_ref.iloc[0])))
97 − ev = pd.DataFrame(ev)
103 + # Event study around episode end (weeks -4..+4), listing FE + week FE; pretrend joint test — vectorised merge
104 + ks = pd.DataFrame({"k": range(-4, 5)})
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
106 + ev = evb.merge(sf[["uid", "week", "log_p", "log_ref"]], on=["uid", "week"], how="inner")[["uid", "week", "k", "log_p", "log_ref"]]
98 107 if len(ev):
99 108 ev["k_f"] = ev.k.astype(int)
100 109 fit = pf.feols("log_p ~ i(k_f, ref=-1) | uid + week", data=ev, vcov={"CRV1": "uid"})
101 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 112 try:
104 113 w = fit.wald_test(R=None, q=None) if False else None
105 114 except Exception: w = None
@@ -107,6 +116,15 @@ def main():
107 116 W = float(b @ np.linalg.solve(Vs, b)); pval = float(1 - stats.chi2.cdf(W, len(pre)))
108 117 out["pretrend_wald"] = W; out["pretrend_p"] = pval; out["event_study_n"] = int(fit._N)
109 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 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 129 # Placebo: random pseudo-episode ends for non-promoted listings
112 130 rng = np.random.default_rng(SEED); pl = []
@@ -126,8 +144,9 @@ def main():
126 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 145 (E / "modelB_summary.json").write_text(json.dumps(out, indent=2, default=float))
128 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",
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")))
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)",
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 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 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 3 For each complete week: map each basket item to candidate listings per banner (category + name regex, parsable size,
4 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 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 13 import json, re, numpy as np, pandas as pd, pulp, yaml
8 14 from src.common import path, ROOT, get_logger, ledger
@@ -15,6 +21,7 @@ def candidates(pw, items):
15 21 if it.get("exclude_regex"): d = d[~d.name.fillna("").str.contains(it["exclude_regex"], case=False, regex=True)]
16 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 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 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 26 rows.append(d[["item", "week", "banner", "company", "uid", "name", "size_label", "p_mode", "unit_p", "item_cost"]])
20 27 return pd.concat(rows, ignore_index=True)
@@ -45,25 +52,44 @@ def main():
45 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 53 log.info("basket items with candidates: %d / %d ; median banners per item %.0f", cov.item.nunique(), len(items), cov.banners.median())
47 54 results = []
48 − for w, d in cand.groupby("week"):
49 − tbl = d.groupby(["item", "banner"]).item_cost.min().unstack()
50 − # keep banners carrying >= 60 % of covered items, and items available at >= 2 banners (so that K=1 is feasible on a common set)
51 − tbl = tbl.loc[:, tbl.notna().mean() >= 0.6]
52 − tbl = tbl[tbl.notna().sum(axis=1) >= 2]
53 − # for K=1 feasibility, restrict to items available at every retained banner
54 − full = tbl.dropna()
55 − for K in (1, 2, 3, 4, 5, len(tbl.columns)):
56 − tot, used, n = solve(full, K)
57 − results.append(dict(week=w, K=K if K < len(tbl.columns) else 99, cost=tot, banners=";".join(used), n_items=n, n_banners_avail=len(tbl.columns)))
58 − # single-banner cost for each banner (full item set)
59 − for b in full.columns:
60 − results.append(dict(week=w, K=1, cost=float(full[b].sum()), banners=b, n_items=len(full), n_banners_avail=len(tbl.columns), single=True))
61 − for fc in (5.0, 10.0, 20.0):
62 − tot, used, n = solve(full, len(full.columns), fixed_cost=fc)
63 − results.append(dict(week=w, K=99, cost=tot - fc * len(used), banners=";".join(used), n_items=n, n_banners_avail=len(tbl.columns), fixed_cost=fc, n_used=len(used), cost_incl_travel=tot))
55 + for B in (4, 5, 6, 7):
56 + for w, d in cand.groupby("week"):
57 + tbl = d.groupby(["item", "banner"]).item_cost.min().unstack()
58 + top = tbl.notna().sum().sort_values(ascending=False).index[:B] # A-001: widest-coverage banners
59 + full = tbl[list(top)].dropna() # items carried by all B banners
60 + if len(full) < 5: continue
61 + for K in (1, 2, 3, 4, 5, B):
62 + if K > B: continue
63 + tot, used, n = solve(full, K)
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))
65 + 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))
67 + if B == 5:
68 + for fc in (5.0, 10.0, 20.0):
69 + tot, used, n = solve(full, B, fixed_cost=fc)
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 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()]
66 − front = base.groupby("K").cost.mean(); singles = r[r.get("single") == True].groupby("week").cost.agg(["min", "max", "mean"])
85 + r5 = r[r.B == 5]
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 93 out["items_in_common_set_mean"] = float(base.n_items.mean()); out["banners_available_mean"] = float(base.n_banners_avail.mean())
68 94 out["frontier_mean_cost_by_K"] = {int(k): float(v) for k, v in front.items()}
69 95 c1, c2, cinf = front.get(1), front.get(2), front.get(99)
@@ -72,7 +98,7 @@ def main():
72 98 out["single_banner_cost_spread_mean"] = float((singles["max"] - singles["min"]).mean()); out["single_banner_mean_cost"] = float(singles["mean"].mean())
73 99 out["most_expensive_vs_cheapest_single_share"] = float(((singles["max"] - singles["min"]) / singles["min"]).mean())
74 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 102 tc.to_csv(P / "modelE_travel_sensitivity.csv", index=False); out["travel_cost_sensitivity"] = tc.to_dict(orient="records")
77 103 (E / "modelE_summary.json").write_text(json.dumps(out, indent=2, default=float))
78 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
Menu

Theme