SPB Git

spb/wp37_uqo

Public

phase3: preregistration frozen (hypotheses H1-H6, estimands, basket, QGPAI components) + decision log

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

3 changed files +121 −0

added config/basket.yaml +34 −0
@@ -0,0 +1,34 @@
1 +# Preregistered fixed basket (30 generic grocery items, weekly household reference quantities) — defined BEFORE any
2 +# basket cost was computed (2026-11-01). Items are mapped to matched groups by category and name keywords (regex, FR/EN).
3 +# quantity: in base units (g / ml / un) used to scale unit prices.
4 +items:
5 + - {id: milk_2pct, category: "Produits laitiers et œufs", name_regex: "lait.*2 ?%|2 ?%.*(lait|milk)|milk.*2 ?%", qty: 4000, base: ml}
6 + - {id: eggs_12, category: "Produits laitiers et œufs", name_regex: "oeufs|œufs|eggs", qty: 12, base: un}
7 + - {id: butter_454, category: "Produits laitiers et œufs", name_regex: "beurre|butter", exclude_regex: "arachide|peanut|vegetal|margarine", qty: 454, base: g}
8 + - {id: cheddar, category: "Charcuteries et fromages", name_regex: "cheddar", qty: 400, base: g}
9 + - {id: yogurt_plain, category: "Produits laitiers et œufs", name_regex: "yogourt|yogurt", qty: 750, base: g}
10 + - {id: bread_white, category: "Boulangerie", name_regex: "pain|bread", exclude_regex: "pita|naan|hamburger|hot ?dog|tortilla", qty: 675, base: g}
11 + - {id: pasta_410, category: "Garde-manger", name_regex: "spaghetti|penne|fusilli|rotini|linguine|macaroni|p[aâ]tes|pasta", exclude_regex: "sauce|fra[iî]ches|frais|gluten", qty: 500, base: g}
12 + - {id: rice, category: "Garde-manger", name_regex: "\\briz\\b|\\brice\\b", qty: 900, base: g}
13 + - {id: flour, category: "Garde-manger", name_regex: "farine|flour", qty: 2500, base: g}
14 + - {id: sugar, category: "Garde-manger", name_regex: "sucre|sugar", exclude_regex: "sans sucre|sugar.?free|zero", qty: 2000, base: g}
15 + - {id: oil_canola, category: "Garde-manger", name_regex: "huile.*(canola|v[eé]g[eé]tale)|canola oil|vegetable oil", qty: 1000, base: ml}
16 + - {id: peanut_butter, category: "Garde-manger", name_regex: "beurre d.arachide|peanut butter", qty: 500, base: g}
17 + - {id: tomato_sauce, category: "Garde-manger", name_regex: "sauce.*(tomate|pizza|spaghetti|pasta)|pasta sauce|tomato sauce", qty: 650, base: ml}
18 + - {id: canned_tuna, category: "Garde-manger", name_regex: "thon|tuna", qty: 170, base: g}
19 + - {id: cereal, category: "Garde-manger", name_regex: "c[eé]r[eé]ales|cereal|cheerios|flocons", qty: 500, base: g}
20 + - {id: coffee_ground, category: "Boissons", name_regex: "caf[eé].*(moulu|ground)|ground coffee", qty: 340, base: g}
21 + - {id: orange_juice, category: "Boissons", name_regex: "jus d.orange|orange juice", qty: 1750, base: ml}
22 + - {id: cola_2l, category: "Boissons", name_regex: "cola", exclude_regex: "di[eè]te|diet|z[eé]ro|zero|cerise|cherry|vanille|vanilla", qty: 2000, base: ml}
23 + - {id: water_24, category: "Boissons", name_regex: "eau.*(source|spring)|spring water|eau.*bouteille", qty: 12000, base: ml}
24 + - {id: chicken_breast, category: "Viandes et volailles", name_regex: "poitrine.*poulet|chicken breast", qty: 1000, base: g}
25 + - {id: ground_beef, category: "Viandes et volailles", name_regex: "b(oe|œ)uf hach[eé]|ground beef", qty: 1000, base: g}
26 + - {id: bacon, category: "Charcuteries et fromages", name_regex: "bacon", exclude_regex: "dinde|turkey|bits|miettes", qty: 375, base: g}
27 + - {id: salmon, category: "Poissons et fruits de mer", name_regex: "saumon|salmon", qty: 400, base: g}
28 + - {id: bananas, category: "Fruits et légumes", name_regex: "banane|banana", qty: 1000, base: g}
29 + - {id: apples, category: "Fruits et légumes", name_regex: "pommes?\\b|apples?\\b", exclude_regex: "jus|juice|compote|terre|potato", qty: 1360, base: g}
30 + - {id: potatoes, category: "Fruits et légumes", name_regex: "pommes de terre|potatoes", qty: 4540, base: g}
31 + - {id: onions, category: "Fruits et légumes", name_regex: "oignons?|onions?", qty: 1360, base: g}
32 + - {id: carrots, category: "Fruits et légumes", name_regex: "carottes?|carrots?", qty: 907, base: g}
33 + - {id: frozen_veg, category: "Surgelés", name_regex: "l[eé]gumes.*(surgel|m[eé]lang)|mixed vegetables|frozen vegetables", qty: 750, base: g}
34 + - {id: chips, category: "Collations et confiseries", name_regex: "croustilles|chips", exclude_regex: "tortilla|pita|chocolate|chocolat", qty: 200, base: g}
added research/decisions.md +8 −0
@@ -0,0 +1,8 @@
1 +# Decision log — WP37
2 +
3 +| UTC | Decision | Rationale |
4 +|---|---|---|
5 +| 2026-11-01T08:2x | Posted price = weekly duration-weighted mode | store-mixture oscillation in anti-bot storefront feeds (Phase 0 §3.3) |
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 |
8 +| 2026-11-01T08:2x | **Preregistration frozen** | sha256 research/preregistration.md = 4dcb257ee9a1d335d51f639eece1d22719ff7031f9735ebf977409c8a7a0b96e ; config/basket.yaml = c9a0f56faf225ab242f7f623e1c948ea88c86b1fdc617da9a51986ef1ba5ad45 |
added research/preregistration.md +79 −0
@@ -0,0 +1,79 @@
1 +# WP37 — Preregistration of hypotheses, samples, estimands and analysis plan
2 +
3 +Status: **FROZEN** on 2026-11-01 before any hypothesis-relevant estimation (hash recorded in `research/decisions.md`). Amendments after this date are logged in `research/decisions.md` with a reason; the paper reports both the preregistered and any amended analysis.
4 +
5 +## 0. What was examined before freezing (disclosure)
6 +
7 +Phase 0–1 work looked at: table counts, per-feed coverage, the event-log data-generating process, intra-day price oscillation diagnostics (share of A→B→A reversions, number of distinct price values, weekly Jaccard of value sets), unit-price coverage, the distribution of current discount depth (`1 − price/regular_price`, median 0.17), UPC validity, matching precision/recall and the validation sample. No cross-banner price gap, promotion duration, index, response model or forecast has been computed. The store-mixture finding (Phase 0 report §3.3) motivated the weekly modal-price definition below; this is a data-quality decision, not a result-driven one.
8 +
9 +## 1. Universe, period and units
10 +
11 +- Snapshot `foodka-snapshot-20261101` (sha256 `3cd7684d…`), event log 2026-08-12 → 2026-11-01, **12 complete circular weeks** (Thu 2026-08-13 → Wed 2026-11-04 truncated at 2026-11-01; the first partial week 2026-08-06 and the last partial week 2026-10-29 are dropped from weekly analyses unless stated).
12 +- Grocery universe = listings from feeds whose `store_format` is not pharmacy or alcohol monopoly, in categories other than {Santé et beauté, Maison et entretien, Animaux, Bébé, Alcool}, with ≥ 1 observed price and no unrealistic-price flag.
13 +- **Posted price of listing i in week w** = duration-weighted modal price `p_mode` over the week (primary); `p_min`, `p_max`, `p_twmean` as sensitivity. Unit price = posted price / parsed package quantity (per 100 g, 100 ml or unit).
14 +- **Matched product** = cross-banner match group built from **tiers A + B** (primary); A+B+C and A+B+C+D in sensitivity; groups with > 1 simultaneously active listing from the same banner-channel are flagged `ambiguous` and excluded from the primary sample.
15 +- Channel: storefront vs circular. Same-banner channel links are not cross-banner comparisons.
16 +- Clustering default: two-way by match group and by banner (banner clusters are few — 17 banners with storefront feeds — so wild-cluster bootstrap p-values are reported alongside).
17 +
18 +## 2. Hypotheses
19 +
20 +### H1 — Matched-item cross-banner dispersion is economically meaningful and persistent
21 +- Sample: product-weeks of matched groups (A+B) with ≥ 2 banners posting a price in the same week; storefront channel only (primary), circulars added in sensitivity.
22 +- Outcome: `log p_{irw}`; dispersion statistics per group-week: coefficient of variation, max–min log gap, interquartile log range.
23 +- Estimand (Model A): `log p_irw = α_iw + γ_r + ε_irw` (product-week FE, banner FE). Banner effects γ_r are relative posted price levels; dispersion = sd of residual + sd of γ. Persistence: autocorrelation of the banner-pair log gap `g_{i,rs,w}` across weeks (AR(1) coefficient), share of pairs whose ranking never flips.
24 +- Null: median within-group max–min log gap = 0 (trivially rejected); the **economic threshold** we preregister is a median gap ≥ 0.05 (5 %) and AR(1) ≥ 0.5 for "meaningful and persistent".
25 +- Controls: none beyond FE in the primary spec; sensitivity adds category × week FE.
26 +- Missingness: a banner-week without a posted price is missing, not zero; groups with intermittent coverage are kept (unbalanced) and a balanced common-product panel is reported as robustness.
27 +- Falsification: Metro vs Super C exact-UPC pairs (same company, two formats) should show *smaller* dispersion than cross-company pairs if company-level pricing coordination exists; placebo: random re-assignment of banners within group-week should yield AR(1) ≈ 0.
28 +- Minimum duration: 8 weeks for AR(1); available.
29 +
30 +### H2 — Promotion-adjusted price volatility differs from non-promotion volatility
31 +- Sample: listing-weeks of storefront feeds (grocery universe) with ≥ 8 observed weeks.
32 +- Promotion state inferred (storefront): a week is "promoted" if `p_mode_w ≤ 0.90 × reference_w`, where reference_w is the modal price over the trailing 8 weeks (including w) — the Nakamura–Steinsson/Kehoe–Midrigan reference filter; validated against the current `on_sale` flag in the snapshot week (report agreement) and against circular items of the same banner-week when matched.
33 +- Outcome: sd of weekly log price changes (i) raw, (ii) excluding promotion weeks (reference-price series).
34 +- Estimand: ratio of raw to reference-price volatility by banner and category; share of all weekly log changes attributable to promotion entry/exit.
35 +- Null: ratio = 1. Clustering: by banner × category (bootstrap over listings).
36 +- Falsification: single-store online shops (Shopify/WooCommerce feeds) must not display the 3–5-level oscillation; if they do, the store-mixture interpretation fails and H2 is re-examined at the daily frequency.
37 +- Missingness: weeks with no observed price are dropped from the change series (no interpolation).
38 +
39 +### H3 — Promotions are associated with subsequent reference-price adjustments
40 +- Sample: promotion episodes (≥ 1 promoted week followed by return to a non-promoted week) in storefront feeds; circular items matched to a storefront listing of the same banner (tier channel link) provide the second sample with observed `original_price`.
41 +- Outcome: change in the reference price after the episode vs before: `Δ log ref = log ref_{post} − log ref_{pre}`.
42 +- Estimand (Model B): transition matrix regular↔promoted; episode duration, recurrence interval, depth; event-study of log price from −4 to +4 weeks around episode end with listing FE and week FE; test of pretrends (weeks −4…−2 coefficients jointly = 0).
43 +- Null: E[Δ log ref] = 0 (promotions neither precede nor follow reference increases). Direction is left to the data.
44 +- Falsification/censoring: episodes censored at panel edges are excluded; overlapping episodes within the same listing are merged; placebo events at random weeks must give Δ ≈ 0.
45 +- Minimum duration: 26 weeks for a well-powered test; with 12 weeks the test is **exploratory** and labelled so.
46 +
47 +### H4 — Competitor responses are strongest for close substitutes and nearby stores
48 +- Store geography is unavailable → the "nearby stores" part is **not testable** and is stated as such. The testable part: banner-level weekly response of `Δ log p_{irw}` to lagged rival changes `Δ log p_{isw−k}` (k = 0…3) within matched group, with product-week and banner FE (Model C), separately for same-company rivals (Metro/Super C; Maxi/Provigo/Club Entrepôt) vs cross-company rivals.
49 +- Estimand: distributed-lag coefficients; contemporaneous (k = 0) coefficients are **not** causal (common shocks, simultaneity); k ≥ 1 coefficients are descriptive lead–lag associations. No IV/quasi-experiment is preregistered because no exogenous shifter is available.
50 +- Null: all lagged coefficients = 0.
51 +- Clustering: two-way group × week. Falsification: placebo with rival changes shifted forward (leads) must be ≈ 0.
52 +
53 +### H5 — Package-size and assortment changes create gaps between naive and quality-consistent indices
54 +- Sample: grocery universe, weekly, storefront + circular.
55 +- Indices (Model D): (a) naive average posted price by category; (b) Jevons matched-model index (geometric mean of weekly log changes over listings present in both weeks); (c) fixed-basket Laspeyres with equal item weights (no expenditure weights exist — explicitly **not** a CPI); (d) chained version; (e) unit-price versions of (b)–(d) that re-match listings whose size label changed.
56 +- Estimand: cumulative difference between (a) and (b) over 12 weeks; count and unit-price effect of within-listing size changes.
57 +- Null: gap = 0. Uncertainty by listing-level bootstrap (B = 999, seed 20261101).
58 +- Benchmark: StatCan CPI "Food purchased from stores", Quebec, Aug→Sep 2026 (2 months) — comparison only, no superiority claim.
59 +
60 +### H6 — Unconstrained minimum-price basket savings exceed savings feasible under constraints
61 +- Basket: 30 generic items specified by category and quantity (preregistered in `config/basket.yaml` before estimation), mapped to matched groups (A+B) and, where no matched group exists, to the cheapest listing per banner within a tight name rule (documented).
62 +- Model E: integer program minimising basket cost subject to at most K banners (K = 1…5), each item bought at one banner, availability = posted price exists that week. Travel costs are **not** observable; a sensitivity grid of fixed per-store costs (0, 5, 10, 20 CAD) is reported as illustrative, labelled as assumption-based.
63 +- Estimand: savings frontier S(K) = cost(K=1 best single banner) − cost(K); share of unconstrained savings attained at K = 2.
64 +- Null: S(∞) − S(2) = 0 (two banners capture everything).
65 +
66 +### Forecasting (Model F) — preregistered evaluation
67 +- Target: next-week posted unit price (log) and next-week promotion indicator per listing (storefront).
68 +- Rolling origin: train weeks 1…t, test week t+1, t = 6…11; models: seasonal naive (last week), banner-category mean reversion, ridge, LightGBM; metrics: MAE / RMSE on log price, Brier / AUC on promotion. No future information in features (strict lags only).
69 +
70 +## 3. QGPAI components (Phase 5) — fixed before computation
71 +1. Comparable Price Index: Jevons matched-model index over matched groups (A+B), weekly, base = week of 2026-08-13 = 100.
72 +2. Promotion Intensity: share of listing-weeks promoted; mean depth; sd of depth — storefront (inferred) and circular (observed) reported separately.
73 +3. Retail Price Dispersion: median within-group max–min log gap (H1 statistic).
74 +4. Feasible Basket Cost: Model E cost at K = 1 and K = 2.
75 +5. Access & Affordability: **not computed** (no geography/income data joined); placeholder documented.
76 +Aggregation: components are published separately; an equal-weight z-score composite is shown only as an illustration with alternative weights (0.5/0.5 on components 1 and 4) — not an empirical estimate.
77 +
78 +## 4. Multiple testing
79 +Six preregistered families (H1–H6); within each, Holm correction over the primary tests. Exploratory analyses are labelled.
80
Menu

Theme