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, 2+ "fixed_block": {
3− "banners_available_mean": 7.7272727272727275, 3+ "banners": [
4+ "Super C",
5+ "Metro",
6+ "Giant Tiger",
7+ "IGA",
8+ "Walmart"
9+ ],
10+ "n_items": 10,
11+ "mean_cost_K1": 32.31608185331482,
12+ "mean_cost_K2": 29.053711571037525,
13+ "mean_cost_unc": 28.020668381660162
14+ },
15+ "block_sensitivity": [
16+ {
17+ "B": 4,
18+ "n_items": 18,
19+ "cost_K1": 78.72061944703458,
20+ "cost_K2": 68.96503896142866,
21+ "cost_unconstrained": 66.86453724461143,
22+ "savings_share": 0.15060961518983287,
23+ "share_at_K2": 0.8228334047491731
24+ },
25+ {
26+ "B": 5,
27+ "n_items": 15,
28+ "cost_K1": 67.28423159055245,
29+ "cost_K2": 58.50114263955248,
30+ "cost_unconstrained": 56.91885773846968,
31+ "savings_share": 0.1540535368696727,
32+ "share_at_K2": 0.8473489790467271
33+ },
34+ {
35+ "B": 6,
36+ "n_items": 11,
37+ "cost_K1": 49.27197360107939,
38+ "cost_K2": 45.0457230043298,
39+ "cost_unconstrained": 43.6571219953653,
40+ "savings_share": 0.11395629594977465,
41+ "share_at_K2": 0.75269141439974
42+ },
43+ {
44+ "B": 7,
45+ "n_items": 9,
46+ "cost_K1": 31.013118925707076,
47+ "cost_K2": 28.327593246343564,
48+ "cost_unconstrained": 27.284743682204486,
49+ "savings_share": 0.12021929340399568,
50+ "share_at_K2": 0.7202938288048005
51+ }
52+ ],
53+ "items_in_common_set_mean": 16.363636363636363,
54+ "banners_available_mean": 5.0,
4 "frontier_mean_cost_by_K": { 55 "frontier_mean_cost_by_K": {
5− "1": 23.12194937122785, 56+ "1": 67.28423159055245,
6− "2": 20.888101262563136, 57+ "2": 58.50114263955248,
7− "3": 20.204123130937315, 58+ "3": 57.245563977839794,
8− "4": 20.054139457868775, 59+ "4": 56.98363046574241,
9− "5": 20.034210164939484, 60+ "99": 56.91885773846968
10− "99": 20.028755619484937
11 }, 61 },
12− "savings_unconstrained_vs_best_single": 3.0931937517429127, 62+ "savings_unconstrained_vs_best_single": 10.365373852082769,
13− "savings_share_unconstrained": 0.1337773775939487, 63+ "savings_share_unconstrained": 0.1540535368696727,
14− "share_of_unconstrained_savings_at_K2": 0.7221817603265276, 64+ "share_of_unconstrained_savings_at_K2": 0.8473489790467271,
15− "single_banner_cost_spread_mean": 39.69805844961497, 65+ "single_banner_cost_spread_mean": 60.83196506195359,
16− "single_banner_mean_cost": 32.88295457993973, 66+ "single_banner_mean_cost": 87.58897378738362,
17− "most_expensive_vs_cheapest_single_share": 1.856238474704022, 67+ "most_expensive_vs_cheapest_single_share": 0.9131745576696246,
18 "travel_cost_sensitivity": [ 68 "travel_cost_sensitivity": [
19 { 69 {
20 "fixed_cost": 5.0, 70 "fixed_cost": 5.0,
21− "n_used": 1.0, 71+ "n_used": 1.8181818181818181,
22− "cost_goods": 18.12194937122785, 72+ "cost_goods": 59.313548870407836,
23− "cost_incl": 23.12194937122785 73+ "cost_incl": 68.40445796131691
24 }, 74 },
25 { 75 {
26 "fixed_cost": 10.0, 76 "fixed_cost": 10.0,
27− "n_used": 1.0, 77+ "n_used": 1.2727272727272727,
28− "cost_goods": 13.12194937122785, 78+ "cost_goods": 63.538701559764334,
29− "cost_incl": 23.12194937122785 79+ "cost_incl": 76.26597428703707
30 }, 80 },
31 { 81 {
32 "fixed_cost": 20.0, 82 "fixed_cost": 20.0,
33 "n_used": 1.0, 83 "n_used": 1.0,
34− "cost_goods": 3.121949371227848, 84+ "cost_goods": 67.28423159055245,
35− "cost_incl": 23.12194937122785 85+ "cost_incl": 87.28423159055245
36 } 86 }
37 ] 87 ]
38 } 88 }
\ No newline at end of file
added experiments/qgpai_summary.json +225 −0
@@ -0,0 +1,225 @@
1+{
2+ "components": [
3+ {
4+ "week": "2026-08-13 00:00:00",
5+ "comparable_price_index": 100.0,
6+ "cpi_lo": 100.0,
7+ "cpi_hi": 100.0,
8+ "dispersion_median_gap": 0.1546,
9+ "disp_lo": 0.1532,
10+ "disp_hi": 0.1648,
11+ "promo_share": NaN,
12+ "depth_mean": NaN,
13+ "depth_sd": NaN,
14+ "basket_K1": 34.1664,
15+ "basket_K2": 31.4286,
16+ "basket_unconstrained": 29.5234,
17+ "circular_depth_median": 0.2236,
18+ "circular_items": 469,
19+ "access_affordability": NaN,
20+ "composite_equal_illustrative": NaN,
21+ "composite_alt_illustrative": 0.6251
22+ },
23+ {
24+ "week": "2026-08-20 00:00:00",
25+ "comparable_price_index": 99.654,
26+ "cpi_lo": 99.2336,
27+ "cpi_hi": 100.0892,
28+ "dispersion_median_gap": 0.1542,
29+ "disp_lo": 0.1487,
30+ "disp_hi": 0.1546,
31+ "promo_share": 0.0427,
32+ "depth_mean": 0.2905,
33+ "depth_sd": 0.1901,
34+ "basket_K1": 32.4038,
35+ "basket_K2": 29.7347,
36+ "basket_unconstrained": 28.951,
37+ "circular_depth_median": 0.2241,
38+ "circular_items": 483,
39+ "access_affordability": NaN,
40+ "composite_equal_illustrative": 0.1338,
41+ "composite_alt_illustrative": -0.347
42+ },
43+ {
44+ "week": "2026-08-27 00:00:00",
45+ "comparable_price_index": 98.6705,
46+ "cpi_lo": 98.1883,
47+ "cpi_hi": 99.187,
48+ "dispersion_median_gap": 0.1544,
49+ "disp_lo": 0.1542,
50+ "disp_hi": 0.1546,
51+ "promo_share": 0.029,
52+ "depth_mean": 0.3293,
53+ "depth_sd": 0.2052,
54+ "basket_K1": 34.8666,
55+ "basket_K2": 31.0668,
56+ "basket_unconstrained": 29.5677,
57+ "circular_depth_median": 0.2463,
58+ "circular_items": 486,
59+ "access_affordability": NaN,
60+ "composite_equal_illustrative": 0.6202,
61+ "composite_alt_illustrative": -0.0335
62+ },
63+ {
64+ "week": "2026-09-03 00:00:00",
65+ "comparable_price_index": 99.4984,
66+ "cpi_lo": 99.0486,
67+ "cpi_hi": 99.9052,
68+ "dispersion_median_gap": 0.1544,
69+ "disp_lo": 0.1512,
70+ "disp_hi": 0.1546,
71+ "promo_share": 0.0436,
72+ "depth_mean": 0.2906,
73+ "depth_sd": 0.1997,
74+ "basket_K1": 33.0893,
75+ "basket_K2": 29.2395,
76+ "basket_unconstrained": 27.9327,
77+ "circular_depth_median": 0.2303,
78+ "circular_items": 530,
79+ "access_affordability": NaN,
80+ "composite_equal_illustrative": 0.2142,
81+ "composite_alt_illustrative": -0.1756
82+ },
83+ {
84+ "week": "2026-09-10 00:00:00",
85+ "comparable_price_index": 100.0647,
86+ "cpi_lo": 99.5442,
87+ "cpi_hi": 100.4976,
88+ "dispersion_median_gap": 0.1544,
89+ "disp_lo": 0.1464,
90+ "disp_hi": 0.1546,
91+ "promo_share": 0.0425,
92+ "depth_mean": 0.2885,
93+ "depth_sd": 0.2047,
94+ "basket_K1": 31.8767,
95+ "basket_K2": 29.6128,
96+ "basket_unconstrained": 28.1203,
97+ "circular_depth_median": 0.237,
98+ "circular_items": 590,
99+ "access_affordability": NaN,
100+ "composite_equal_illustrative": 0.1921,
101+ "composite_alt_illustrative": -0.2716
102+ },
103+ {
104+ "week": "2026-09-17 00:00:00",
105+ "comparable_price_index": 100.2457,
106+ "cpi_lo": 99.6763,
107+ "cpi_hi": 100.7577,
108+ "dispersion_median_gap": 0.1527,
109+ "disp_lo": 0.1432,
110+ "disp_hi": 0.1544,
111+ "promo_share": 0.0461,
112+ "depth_mean": 0.3141,
113+ "depth_sd": 0.275,
114+ "basket_K1": 32.1657,
115+ "basket_K2": 29.616,
116+ "basket_unconstrained": 28.281,
117+ "circular_depth_median": 0.2236,
118+ "circular_items": 589,
119+ "access_affordability": NaN,
120+ "composite_equal_illustrative": 0.1232,
121+ "composite_alt_illustrative": -0.0236
122+ },
123+ {
124+ "week": "2026-09-24 00:00:00",
125+ "comparable_price_index": 100.3667,
126+ "cpi_lo": 99.7837,
127+ "cpi_hi": 100.8567,
128+ "dispersion_median_gap": 0.1527,
129+ "disp_lo": 0.1433,
130+ "disp_hi": 0.1544,
131+ "promo_share": 0.0516,
132+ "depth_mean": 0.3044,
133+ "depth_sd": 0.2801,
134+ "basket_K1": 32.3216,
135+ "basket_K2": 28.4099,
136+ "basket_unconstrained": 27.1763,
137+ "circular_depth_median": 0.2236,
138+ "circular_items": 595,
139+ "access_affordability": NaN,
140+ "composite_equal_illustrative": 0.072,
141+ "composite_alt_illustrative": 0.1267
142+ },
143+ {
144+ "week": "2026-10-01 00:00:00",
145+ "comparable_price_index": 100.802,
146+ "cpi_lo": 100.2437,
147+ "cpi_hi": 101.3218,
148+ "dispersion_median_gap": 0.1511,
149+ "disp_lo": 0.1404,
150+ "disp_hi": 0.1544,
151+ "promo_share": 0.0578,
152+ "depth_mean": 0.2972,
153+ "depth_sd": 0.2488,
154+ "basket_K1": 31.0905,
155+ "basket_K2": 27.8308,
156+ "basket_unconstrained": 26.9732,
157+ "circular_depth_median": 0.2088,
158+ "circular_items": 692,
159+ "access_affordability": NaN,
160+ "composite_equal_illustrative": -0.272,
161+ "composite_alt_illustrative": -0.0701
162+ },
163+ {
164+ "week": "2026-10-08 00:00:00",
165+ "comparable_price_index": 100.8131,
166+ "cpi_lo": 100.207,
167+ "cpi_hi": 101.3248,
168+ "dispersion_median_gap": 0.1513,
169+ "disp_lo": 0.1427,
170+ "disp_hi": 0.1544,
171+ "promo_share": 0.0678,
172+ "depth_mean": 0.2722,
173+ "depth_sd": 0.2434,
174+ "basket_K1": 31.1654,
175+ "basket_K2": 27.6367,
176+ "basket_unconstrained": 26.6867,
177+ "circular_depth_median": 0.2236,
178+ "circular_items": 737,
179+ "access_affordability": NaN,
180+ "composite_equal_illustrative": -0.4719,
181+ "composite_alt_illustrative": -0.0313
182+ },
183+ {
184+ "week": "2026-10-15 00:00:00",
185+ "comparable_price_index": 101.3078,
186+ "cpi_lo": 100.789,
187+ "cpi_hi": 101.8291,
188+ "dispersion_median_gap": 0.1407,
189+ "disp_lo": 0.1336,
190+ "disp_hi": 0.1516,
191+ "promo_share": 0.0636,
192+ "depth_mean": 0.2608,
193+ "depth_sd": 0.2192,
194+ "basket_K1": 31.1654,
195+ "basket_K2": 27.1084,
196+ "basket_unconstrained": 27.1084,
197+ "circular_depth_median": 0.2371,
198+ "circular_items": 661,
199+ "access_affordability": NaN,
200+ "composite_equal_illustrative": -0.8951,
201+ "composite_alt_illustrative": 0.321
202+ },
203+ {
204+ "week": "2026-10-22 00:00:00",
205+ "comparable_price_index": 100.6884,
206+ "cpi_lo": 100.1056,
207+ "cpi_hi": 101.2119,
208+ "dispersion_median_gap": 0.1513,
209+ "disp_lo": 0.1368,
210+ "disp_hi": 0.1544,
211+ "promo_share": 0.0541,
212+ "depth_mean": 0.2877,
213+ "depth_sd": 0.2573,
214+ "basket_K1": 31.1654,
215+ "basket_K2": 27.9066,
216+ "basket_unconstrained": 27.9066,
217+ "circular_depth_median": 0.2291,
218+ "circular_items": 809,
219+ "access_affordability": NaN,
220+ "composite_equal_illustrative": -0.2028,
221+ "composite_alt_illustrative": -0.1201
222+ }
223+ ],
224+ "note": "component 5 not computed; composites are illustrations with prespecified weights, not estimates"
225+}
\ No newline at end of file
added experiments/robustness_summary.json +68 −0
@@ -0,0 +1,68 @@
1+{
2+ "baseline_median_gap": 0.15415067982725827,
3+ "balanced": {
4+ "groups": 1300,
5+ "median_gap": 0.1634061098014189,
6+ "share_ge_5pct": 0.8048251748251748
7+ },
8+ "unit_price": {
9+ "group_weeks": 29997,
10+ "median_gap": 0.15415067982725839,
11+ "share_ge_5pct": 0.7852118545187852
12+ },
13+ "trimmed_1_99": {
14+ "median_gap": 0.15128944754622609,
15+ "mean_gap": 0.18149910019754395,
16+ "mean_untrimmed": 0.18936608290604118
17+ },
18+ "cross_company_only": {
19+ "group_weeks": 2103,
20+ "median_gap": 0.18254405095440074
21+ },
22+ "non_promo_weeks_only": {
23+ "group_weeks": 26442,
24+ "median_gap": 0.13540463700620298,
25+ "share_ge_5pct": 0.769760229937221
26+ },
27+ "group_weeks_with_a_promo": {
28+ "group_weeks": 4813,
29+ "median_gap": 0.23709263169173989
30+ },
31+ "holm": [
32+ {
33+ "test": "H1_median_gap_gt_5pct",
34+ "p": 0.0,
35+ "p_holm": 0.0
36+ },
37+ {
38+ "test": "H1_AR1_gt_0.5",
39+ "p": 0.0,
40+ "p_holm": 0.0
41+ },
42+ {
43+ "test": "H2_ratio_ne_1",
44+ "p": 0.0,
45+ "p_holm": 0.0
46+ },
47+ {
48+ "test": "H3_all_low_price_episodes_dlog_ref_ne_0",
49+ "p": 0.0,
50+ "p_holm": 0.0
51+ },
52+ {
53+ "test": "H4_lag1_ne_0",
54+ "p": 0.002762665364015371,
55+ "p_holm": 0.008287996092046113
56+ },
57+ {
58+ "test": "H3_return_episodes_dlog_ref_ne_0",
59+ "p": 0.00912808030023271,
60+ "p_holm": 0.01825616060046542
61+ },
62+ {
63+ "test": "H5_naive_vs_jevons_gap_ne_0",
64+ "p": 1.0,
65+ "p_holm": 1.0
66+ }
67+ ]
68+}
\ No newline at end of file
added paper/figures/fig_basket.pdf +0 −0

Binary file not shown.

added paper/figures/fig_coverage.pdf +0 −0

Binary file not shown.

added paper/figures/fig_dispersion.pdf +0 −0

Binary file not shown.

added paper/figures/fig_forecast.pdf +0 −0

Binary file not shown.

added paper/figures/fig_heatmap.pdf +0 −0

Binary file not shown.

added paper/figures/fig_indices.pdf +0 −0

Binary file not shown.

added paper/figures/fig_promotions.pdf +0 −0

Binary file not shown.

added paper/figures/fig_qgpai.pdf +0 −0

Binary file not shown.

added paper/figures/fig_spillovers.pdf +0 −0

Binary file not shown.

added paper/figures/fig_store_mixture.pdf +0 −0

Binary file not shown.

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

Theme