Label namespaces (A-002), hypothesis registry, manuscript sections (lit, setting, data, hypotheses, methods), bib fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
57 changed files +510 −349
added
experiments/hypothesis_registry_rule.json
+93 −0
@@ -0,0 +1,93 @@ | ||
| 1 | +{ | |
| 2 | + "labelling": "rule", | |
| 3 | + "holm_family": [ | |
| 4 | + "H1", | |
| 5 | + "H2", | |
| 6 | + "H3", | |
| 7 | + "H5-T1", | |
| 8 | + "H5-T2" | |
| 9 | + ], | |
| 10 | + "registry": [ | |
| 11 | + { | |
| 12 | + "id": "H1", | |
| 13 | + "statement": "Online assortment composition differs across regions beyond what merchant counts imply", | |
| 14 | + "primary_statistic": "Cramér's V (M) = 0.105, store-reshuffle p = 0.0005; (T) V = 0.159, p = 0.492; (L) p = 0.058", | |
| 15 | + "p_raw": 0.49225387306346824, | |
| 16 | + "p_holm": 0.9434166539280238, | |
| 17 | + "status": "partially supported", | |
| 18 | + "note": "Preregistered criterion required p < 0.05 under both merchant-equal and trimmed-listing weights; met for merchant-equal only. Specialization is a property of the merchant population, not of listing volumes." | |
| 19 | + }, | |
| 20 | + { | |
| 21 | + "id": "H2", | |
| 22 | + "statement": "Regional diversity is associated with regional scale", | |
| 23 | + "primary_statistic": "Spearman rho(rarefied entropy, log population) = -0.20, 95% CI [-0.62; 0.34], n = 16", | |
| 24 | + "p_raw": 0.4717083269640119, | |
| 25 | + "p_holm": 0.9434166539280238, | |
| 26 | + "status": "inconclusive", | |
| 27 | + "note": "CI includes zero and economically meaningful values in both directions; n = 16 regions. No association with business counts or density either (see table)." | |
| 28 | + }, | |
| 29 | + { | |
| 30 | + "id": "H3", | |
| 31 | + "statement": "Merchant catalogue sizes are highly skewed", | |
| 32 | + "primary_statistic": "Gini = 0.849 [0.811; 0.878]; top-10% share = 0.789 [0.730; 0.836]", | |
| 33 | + "p_raw": null, | |
| 34 | + "p_holm": null, | |
| 35 | + "status": "supported", | |
| 36 | + "note": "Both preregistered thresholds (Gini >= 0.70, top-10% >= 0.50) exceeded with CIs entirely above them; power-law tail favoured over log-normal on the tail." | |
| 37 | + }, | |
| 38 | + { | |
| 39 | + "id": "H4", | |
| 40 | + "statement": "Related diversification (exploratory)", | |
| 41 | + "primary_statistic": "LPM density coefficient 0.111 (SE 0.050), placebo p = 0.010; 149 entries; 11 region-level entries", | |
| 42 | + "p_raw": null, | |
| 43 | + "p_holm": null, | |
| 44 | + "status": "exploratory: consistent with relatedness at the store level; region level untestable", | |
| 45 | + "note": "Fewer than 10 region-level entries (preregistered untestability rule); store-level association over 5 weeks, no causal claim." | |
| 46 | + }, | |
| 47 | + { | |
| 48 | + "id": "H5-T1", | |
| 49 | + "statement": "Matched-product prices are geographically structured", | |
| 50 | + "primary_statistic": "Wald(14) = 164.4, wild-cluster-bootstrap p = 0.0045; 1064 groups, 143 stores, 9 regions with >= 5 stores", | |
| 51 | + "p_raw": 0.0045, | |
| 52 | + "p_holm": 0.013499999999999998, | |
| 53 | + "status": "supported", | |
| 54 | + "note": "Region effects identified only through groups spanning regions; posted prices, no shipping/taxes." | |
| 55 | + }, | |
| 56 | + { | |
| 57 | + "id": "H5-T2", | |
| 58 | + "statement": "Matched-product prices are competitively structured (reseller vs producer)", | |
| 59 | + "primary_statistic": "reseller coefficient -0.116 (SE 0.026), wild-bootstrap p = 0.0012", | |
| 60 | + "p_raw": 0.0012, | |
| 61 | + "p_holm": 0.0048, | |
| 62 | + "status": "supported", | |
| 63 | + "note": "Resellers/collectives post lower prices than producers for the same product." | |
| 64 | + }, | |
| 65 | + { | |
| 66 | + "id": "H6", | |
| 67 | + "statement": "Local-origin price premium", | |
| 68 | + "primary_statistic": "not estimated", | |
| 69 | + "p_raw": null, | |
| 70 | + "p_holm": null, | |
| 71 | + "status": "untestable (declared ex ante)", | |
| 72 | + "note": "No product-level provenance; a store-certification contrast is reported descriptively and is not H6." | |
| 73 | + }, | |
| 74 | + { | |
| 75 | + "id": "H7", | |
| 76 | + "statement": "Validated digital-assortment complexity indicator", | |
| 77 | + "primary_statistic": "ECI bootstrap rank stability median rho = 0.20 (criterion 0.8); lambda2 null p = 0.41; LOO predictive rho = 0.39", | |
| 78 | + "p_raw": null, | |
| 79 | + "p_holm": null, | |
| 80 | + "status": "not validated: ECI unstable at 17 x 20; omitted from QDACI", | |
| 81 | + "note": "Fitness and plain diversity are more stable but still below the criterion; the region-category matrix is not more nested than the store-reshuffle null." | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + "id": "QDACI", | |
| 85 | + "statement": "Composite index", | |
| 86 | + "primary_statistic": "median agreement across weightings rho = 0.68 (rule 0.7); bootstrap rank stability 0.50", | |
| 87 | + "p_raw": null, | |
| 88 | + "p_holm": null, | |
| 89 | + "status": "components published; composite withheld", | |
| 90 | + "note": "Components describe observed online catalogues of Quebec-based merchants, not production or output. Complexity omitted because the ECI failed the preregistered stability criterion." | |
| 91 | + } | |
| 92 | + ] | |
| 93 | +} | |
| \ No newline at end of file | ||
modified
literature/references.bib
+4 −4
@@ -29,7 +29,7 @@ | ||
| 29 | 29 | } |
| 30 | 30 | |
| 31 | 31 | @techreport{atn_netendances, |
| 32 | − author = {{Académie de la transformation numérique}}, | |
| 32 | + author = {{Acad{\'e}mie de la transformation num{\'e}rique}}, | |
| 33 | 33 | title = {{NETendances} --- enquêtes sur les usages numériques des Québécois}, |
| 34 | 34 | year = {n.d.}, |
| 35 | 35 | institution = {Académie de la transformation numérique, Université Laval, Québec}, |
@@ -850,7 +850,7 @@ | ||
| 850 | 850 | } |
| 851 | 851 | |
| 852 | 852 | @article{hidalgo2007, |
| 853 | − author = {Hidalgo, C. A. and Klinger, B. and Barabási, A.-L. and Hausmann, R.}, | |
| 853 | + author = {Hidalgo, C. A. and Klinger, B. and Barabási, A.-L. and Hausmann, R.}, | |
| 854 | 854 | title = {The Product Space Conditions the Development of Nations}, |
| 855 | 855 | year = {2007}, |
| 856 | 856 | journal = {Science}, |
@@ -1165,7 +1165,7 @@ | ||
| 1165 | 1165 | } |
| 1166 | 1166 | |
| 1167 | 1167 | @book{oecd2019, |
| 1168 | − author = {,}, | |
| 1168 | + author = {{OECD}}, | |
| 1169 | 1169 | title = {Unpacking E-commerce}, |
| 1170 | 1170 | year = {2019}, |
| 1171 | 1171 | publisher = {OECD Publishing}, |
@@ -1449,7 +1449,7 @@ | ||
| 1449 | 1449 | |
| 1450 | 1450 | @article{whitacre2014, |
| 1451 | 1451 | author = {Whitacre, Brian and Gallardo, Roberto and Strover, Sharon}, |
| 1452 | − title = {Broadband׳s contribution to economic growth in rural areas: Moving towards a causal relationship}, | |
| 1452 | + title = {Broadband's contribution to economic growth in rural areas: Moving towards a causal relationship}, | |
| 1453 | 1453 | year = {2014}, |
| 1454 | 1454 | journal = {Telecommunications Policy}, |
| 1455 | 1455 | volume = {38}, |
modified
paper/appendix/appendix.tex
+13 −86
@@ -1,93 +1,20 @@ | ||
| 1 | −% ============================================================================ | |
| 2 | −% Appendix | |
| 3 | −% ============================================================================ | |
| 4 | −\newpage | |
| 5 | 1 | \begin{appendices} |
| 2 | +\section{Source audit and compliance}\label{app:source} | |
| 3 | +The Phase~0 source audit (\texttt{reports/00\_source\_audit.md}) documents the three tables kept from the snapshot (\texttt{stores}, 4{,}082 rows; \texttt{products}, 465{,}388; \texttt{sync\_log}, 577{,}773), their fields, missingness and the platform's own compliance review per source: public catalogue endpoints only, polite rates, nothing behind authentication, retained attribution and removal on request. The research used no scraping of the platform and a single control request to its public statistics endpoint to date-stamp the counters. | |
| 6 | 4 | |
| 7 | −\renewcommand{\thetable}{A\arabic{table}} | |
| 8 | −\renewcommand{\thefigure}{A\arabic{figure}} | |
| 9 | −\setcounter{table}{0} | |
| 10 | −\setcounter{figure}{0} | |
| 5 | +\section{Data dictionary}\label{app:dictionary} | |
| 6 | +The full field-level dictionary of the cleaned tables (\texttt{listings\_clean}, \texttt{stores\_master}, \texttt{sibling\_stores}, \texttt{match\_pairs}, \texttt{match\_groups}, \texttt{sync\_log}) is generated by the pipeline (\texttt{reports/data\_dictionary.md}). Key derived fields: \texttt{title\_norm} (ASCII-folded, lower-cased title used for duplicate detection and matching); \texttt{f\_analysis\_assort} and \texttt{f\_analysis\_price} (sample membership flags); \texttt{region\_final} and \texttt{region\_source} (assigned region and its provenance: municipality lookup, platform label, FSA-centroid point-in-polygon, or unknown); \texttt{category\_model} (classifier label, Amendment A-002); \texttt{comparable} (variant-structure comparability of a matched pair). | |
| 11 | 7 | |
| 12 | −\section{Additional Tables and Figures} | |
| 13 | −\label{app:tables} | |
| 8 | +\section{Category taxonomy and audit details}\label{app:categories} | |
| 9 | +\input{tables/category_audit} | |
| 14 | 10 | |
| 15 | −% --- Appendix Table: Sub-sample Analysis --- | |
| 16 | −\begin{table}[htbp] | |
| 17 | − \centering | |
| 18 | − \begin{threeparttable} | |
| 19 | − \caption{Sub-sample Analysis} | |
| 20 | − \label{tab:app_subsample} | |
| 21 | − \begin{tabular}{@{}lccc@{}} | |
| 22 | − \toprule | |
| 23 | − & (1) & (2) & (3) \\ | |
| 24 | − & Full Sample & Pre-2008 & Post-2008 \\ | |
| 25 | − \midrule | |
| 26 | − $X_{i,t}$ & 0.172$^{***}$ & 0.195$^{***}$ & 0.148$^{***}$ \\ | |
| 27 | − & (0.033) & (0.048) & (0.041) \\[6pt] | |
| 28 | − \midrule | |
| 29 | − Controls & Yes & Yes & Yes \\ | |
| 30 | − Firm FE & Yes & Yes & Yes \\ | |
| 31 | − Time FE & Yes & Yes & Yes \\[3pt] | |
| 32 | − Observations & 125{,}400 & 58{,}200 & 67{,}200 \\ | |
| 33 | − Adjusted $R^2$ & 0.190 & 0.175 & 0.203 \\ | |
| 34 | − \bottomrule | |
| 35 | − \end{tabular} | |
| 36 | − \begin{tablenotes}[flushleft] | |
| 37 | − \footnotesize | |
| 38 | − \item \textit{Notes:} This table reports sub-sample results. Column~(1) reproduces the baseline estimate from Table~\ref{tab:regression}, Column~(4). Columns~(2) and~(3) split the sample before and after January~2008. All specifications include the full set of controls and firm and time fixed effects. Standard errors clustered at the firm level are in parentheses. $^{***}$, $^{**}$, and $^{*}$ denote significance at the 1\%, 5\%, and 10\% levels. | |
| 39 | − \end{tablenotes} | |
| 40 | − \end{threeparttable} | |
| 41 | −\end{table} | |
| 11 | +\section{Within-region concentration}\label{app:concentration} | |
| 12 | +\input{tables/h3_within_region} | |
| 42 | 13 | |
| 43 | −% --- Appendix Table: Robustness Checks --- | |
| 44 | −\begin{table}[htbp] | |
| 45 | − \centering | |
| 46 | − \begin{threeparttable} | |
| 47 | − \caption{Robustness Checks} | |
| 48 | − \label{tab:app_robustness} | |
| 49 | − \begin{tabular}{@{}lccc@{}} | |
| 50 | − \toprule | |
| 51 | − & (1) & (2) & (3) \\ | |
| 52 | − & Alt.\ Dep.\ Var. & Alt.\ Controls & Winsorized 5\% \\ | |
| 53 | − \midrule | |
| 54 | − $X_{i,t}$ & 0.165$^{***}$ & 0.178$^{***}$ & 0.169$^{***}$ \\ | |
| 55 | − & (0.036) & (0.034) & (0.032) \\[6pt] | |
| 56 | − \midrule | |
| 57 | − Controls & Yes & Yes & Yes \\ | |
| 58 | − Firm FE & Yes & Yes & Yes \\ | |
| 59 | − Time FE & Yes & Yes & Yes \\[3pt] | |
| 60 | − Observations & 125{,}400 & 125{,}400 & 119{,}800 \\ | |
| 61 | − Adjusted $R^2$ & 0.185 & 0.192 & 0.195 \\ | |
| 62 | − \bottomrule | |
| 63 | − \end{tabular} | |
| 64 | − \begin{tablenotes}[flushleft] | |
| 65 | − \footnotesize | |
| 66 | − \item \textit{Notes:} This table reports robustness checks. Column~(1) uses an alternative dependent variable. Column~(2) includes additional control variables. Column~(3) winsorizes all continuous variables at the 5th and 95th percentiles. All specifications include firm and time fixed effects. Standard errors clustered at the firm level are in parentheses. $^{***}$, $^{**}$, and $^{*}$ denote significance at the 1\%, 5\%, and 10\% levels. | |
| 67 | − \end{tablenotes} | |
| 68 | − \end{threeparttable} | |
| 69 | −\end{table} | |
| 70 | − | |
| 71 | −\section{Variable Definitions} | |
| 72 | −\label{app:variables} | |
| 73 | − | |
| 74 | −\begin{table}[htbp] | |
| 75 | − \centering | |
| 76 | − \caption{Variable Definitions} | |
| 77 | − \label{tab:app_variables} | |
| 78 | − \begin{tabular}{@{}p{3.5cm}p{10cm}@{}} | |
| 79 | − \toprule | |
| 80 | − Variable & Definition \\ | |
| 81 | − \midrule | |
| 82 | − $r_{i,t}$ & Monthly log return of firm $i$ in month $t$, computed as $\ln(P_{i,t}/P_{i,t-1})$. \\[4pt] | |
| 83 | − $\sigma_{i,t}$ & Monthly realized volatility of firm $i$, computed as the standard deviation of daily returns within month $t$, annualized by multiplying by $\sqrt{252}$. \\[4pt] | |
| 84 | − $X_{i,t}$ & Main explanatory variable. [Provide specific definition.] \\[4pt] | |
| 85 | − Size & Natural logarithm of market capitalization (in millions of USD) at the end of month $t-1$. \\[4pt] | |
| 86 | − Book-to-Market & Ratio of book equity to market equity, using the most recent fiscal year-end book value and the market value at the end of June. Updated annually following \citet{fama1993common}. \\[4pt] | |
| 87 | − Leverage & Total debt divided by total assets, measured at the most recent fiscal year-end. \\[4pt] | |
| 88 | − ROA & Return on assets, defined as net income divided by total assets. \\ | |
| 89 | − \bottomrule | |
| 90 | − \end{tabular} | |
| 91 | −\end{table} | |
| 14 | +\section{Region-level indicators}\label{app:indicators} | |
| 15 | +\input{tables/h7_indicators} | |
| 16 | +\input{tables/qdaci} | |
| 92 | 17 | |
| 18 | +\section{Preregistration, decision log and amendments}\label{app:prereg} | |
| 19 | +The frozen preregistration (\texttt{research/preregistration.md}), the decision log (\texttt{research/decisions.md}, D-001 to D-007) and the two amendments are reproduced in the repository. A-001 (region assignment) replaced ``platform label first'' by ``official geography first'' after a first attempt with the Statistics Canada 2021 economic-region layer produced spurious Estrie/Montérégie conflicts that predate the 2021 boundary change; the final rule uses the MRNF municipality table for urban forward sortation areas, then the platform label, then the FSA-centroid point-in-polygon. A-002 (category labels) introduced a supervised classifier after the audit measured the keyword rules at 46\% precision, with the primacy rule stated before any result was computed under the new labels. | |
| 93 | 20 | \end{appendices} |
modified
paper/figures/attrition.pdf
+0 −0
Binary file not shown.
modified
paper/figures/category_network.pdf
+0 −0
Binary file not shown.
modified
paper/figures/coverage_map.pdf
+0 −0
Binary file not shown.
modified
paper/figures/diversity_region.pdf
+0 −0
Binary file not shown.
modified
paper/figures/h4_density.pdf
+0 −0
Binary file not shown.
modified
paper/figures/h5_region_effects.pdf
+0 −0
Binary file not shown.
modified
paper/figures/h7_validation.pdf
+0 −0
Binary file not shown.
modified
paper/figures/price_gaps.pdf
+0 −0
Binary file not shown.
modified
paper/figures/raa_heatmap.pdf
+0 −0
Binary file not shown.
modified
paper/figures/rank_size.pdf
+0 −0
Binary file not shown.
modified
paper/figures/robustness_h1.pdf
+0 −0
Binary file not shown.
added
paper/main.pdf
+0 −0
Binary file not shown.
modified
paper/main.tex
+13 −22
@@ -42,6 +42,7 @@ | ||
| 42 | 42 | \usepackage{graphicx} |
| 43 | 43 | \usepackage{subcaption} |
| 44 | 44 | \usepackage{float} |
| 45 | +\usepackage{adjustbox} | |
| 45 | 46 | |
| 46 | 47 | % --- Colors & Links --- |
| 47 | 48 | \usepackage[dvipsnames]{xcolor} |
@@ -89,19 +90,14 @@ | ||
| 89 | 90 | % ============================================================================ |
| 90 | 91 | % METADATA — Edit these fields for each working paper |
| 91 | 92 | % ============================================================================ |
| 92 | −\newcommand{\WPnumber}{XX} | |
| 93 | −\newcommand{\WPtitle}{Your Working Paper Title Here} | |
| 94 | −\newcommand{\WPsubtitle}{} % Leave empty if no subtitle | |
| 95 | −\newcommand{\WPdate}{\today} | |
| 93 | +\newcommand{\WPnumber}{38} | |
| 94 | +\newcommand{\WPtitle}{The Economic Geography of Digital Production: Product Diversity, Regional Specialization, and the Hidden Structure of Quebec's Online Economy} | |
| 95 | +\newcommand{\WPsubtitle}{Evidence from 390{,}108 product listings of 1{,}569 Quebec online stores across 17 administrative regions (Fabri-Ka snapshot, 1 November 2026)} | |
| 96 | +\newcommand{\WPdate}{November 2026} | |
| 96 | 97 | \newcommand{\WPversion}{1.0} |
| 97 | −\newcommand{\WPabstract}{% | |
| 98 | − This paper examines \ldots{} | |
| 99 | − We find that \ldots{} | |
| 100 | − Our results suggest \ldots{} | |
| 101 | − These findings contribute to the literature on \ldots{} | |
| 102 | −} | |
| 103 | −\newcommand{\WPkeywords}{Keyword 1, Keyword 2, Keyword 3, Keyword 4} | |
| 104 | −\newcommand{\WPjel}{G12, G14, C58} | |
| 98 | +\input{sections/abstract} | |
| 99 | +\newcommand{\WPkeywords}{economic geography; online retail; product assortment; revealed comparative advantage; diversity; economic complexity; price dispersion; web-scraped data; Quebec} | |
| 100 | +\newcommand{\WPjel}{R12, L81, O18, C81, D22, R32} | |
| 105 | 101 | |
| 106 | 102 | % --- Author --- |
| 107 | 103 | \newcommand{\WPauthor}{Simon-Pierre Boucher} |
@@ -120,24 +116,19 @@ | ||
| 120 | 116 | % DOCUMENT |
| 121 | 117 | % ============================================================================ |
| 122 | 118 | \begin{document} |
| 123 | − | |
| 124 | −% --- Title Page --- | |
| 125 | 119 | \input{sections/titlepage} |
| 126 | − | |
| 127 | −% --- Main Body --- | |
| 128 | 120 | \input{sections/introduction} |
| 129 | 121 | \input{sections/literature} |
| 122 | +\input{sections/institutional} | |
| 130 | 123 | \input{sections/data} |
| 131 | −\input{sections/methodology} | |
| 124 | +\input{sections/hypotheses} | |
| 125 | +\input{sections/methods} | |
| 132 | 126 | \input{sections/results} |
| 133 | 127 | \input{sections/robustness} |
| 128 | +\input{sections/discussion} | |
| 134 | 129 | \input{sections/conclusion} |
| 135 | − | |
| 136 | −% --- References --- | |
| 130 | +\input{sections/reproducibility} | |
| 137 | 131 | \newpage |
| 138 | 132 | \bibliography{references} |
| 139 | − | |
| 140 | −% --- Appendix --- | |
| 141 | 133 | \input{appendix/appendix} |
| 142 | − | |
| 143 | 134 | \end{document} |
modified
paper/references.bib
+4 −4
@@ -29,7 +29,7 @@ | ||
| 29 | 29 | } |
| 30 | 30 | |
| 31 | 31 | @techreport{atn_netendances, |
| 32 | − author = {{Académie de la transformation numérique}}, | |
| 32 | + author = {{Acad{\'e}mie de la transformation num{\'e}rique}}, | |
| 33 | 33 | title = {{NETendances} --- enquêtes sur les usages numériques des Québécois}, |
| 34 | 34 | year = {n.d.}, |
| 35 | 35 | institution = {Académie de la transformation numérique, Université Laval, Québec}, |
@@ -850,7 +850,7 @@ | ||
| 850 | 850 | } |
| 851 | 851 | |
| 852 | 852 | @article{hidalgo2007, |
| 853 | − author = {Hidalgo, C. A. and Klinger, B. and Barabási, A.-L. and Hausmann, R.}, | |
| 853 | + author = {Hidalgo, C. A. and Klinger, B. and Barabási, A.-L. and Hausmann, R.}, | |
| 854 | 854 | title = {The Product Space Conditions the Development of Nations}, |
| 855 | 855 | year = {2007}, |
| 856 | 856 | journal = {Science}, |
@@ -1165,7 +1165,7 @@ | ||
| 1165 | 1165 | } |
| 1166 | 1166 | |
| 1167 | 1167 | @book{oecd2019, |
| 1168 | − author = {,}, | |
| 1168 | + author = {{OECD}}, | |
| 1169 | 1169 | title = {Unpacking E-commerce}, |
| 1170 | 1170 | year = {2019}, |
| 1171 | 1171 | publisher = {OECD Publishing}, |
@@ -1449,7 +1449,7 @@ | ||
| 1449 | 1449 | |
| 1450 | 1450 | @article{whitacre2014, |
| 1451 | 1451 | author = {Whitacre, Brian and Gallardo, Roberto and Strover, Sharon}, |
| 1452 | − title = {Broadband׳s contribution to economic growth in rural areas: Moving towards a causal relationship}, | |
| 1452 | + title = {Broadband's contribution to economic growth in rural areas: Moving towards a causal relationship}, | |
| 1453 | 1453 | year = {2014}, |
| 1454 | 1454 | journal = {Telecommunications Policy}, |
| 1455 | 1455 | volume = {38}, |
added
paper/sections/abstract.tex
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +\newcommand{\WPabstract}{Abstract to be written after the final run.} | |
modified
paper/sections/conclusion.tex
+2 −12
@@ -1,12 +1,2 @@ | ||
| 1 | −% ============================================================================ | |
| 2 | −\section{Conclusion} | |
| 3 | −\label{sec:conclusion} | |
| 4 | −% ============================================================================ | |
| 5 | − | |
| 6 | −This paper investigates \ldots{} Using data from \ldots{} over the period \ldots{}, we find that \ldots{} | |
| 7 | − | |
| 8 | −Our results contribute to the literature in several ways. First, \ldots{} Second, \ldots{} Third, \ldots{} | |
| 9 | − | |
| 10 | −These findings have important implications for \ldots{} From a policy perspective, \ldots{} For practitioners, \ldots{} | |
| 11 | − | |
| 12 | −Several limitations of the present study suggest directions for future research. First, \ldots{} Second, \ldots{} We leave these questions for future work. | |
| 1 | +\section{conclusion}\label{sec:conclusion} | |
| 2 | +TODO. | |
modified
paper/sections/data.tex
+20 −23
@@ -1,32 +1,29 @@ | ||
| 1 | −% ============================================================================ | |
| 2 | −\section{Data} | |
| 3 | −\label{sec:data} | |
| 4 | −% ============================================================================ | |
| 1 | +\section{Data: snapshot, cleaning, provenance and representativeness}\label{sec:data} | |
| 5 | 2 | |
| 6 | −\subsection{Data Sources} | |
| 7 | −\label{subsec:data_sources} | |
| 3 | +\subsection{Snapshot and samples} | |
| 4 | +The research copy is a read-only backup of the Fabri-Ka production database taken on 1 November 2026 at 17:38 UTC (application commit \texttt{689c7bb}); the only tables containing personal data (two user accounts, one self-registration) and the derived full-text index were dropped on the production node before the copy left it. The copy holds 465{,}388 product rows, of which 390{,}108 are active and published by the platform's own rules --- gift cards, workshops, subscriptions, tickets and price-out-of-bounds quarantines are excluded upstream --- in 1{,}569 enabled stores, plus 577{,}773 synchronisation events. Table~\ref{tab:sample_flow} and Figure~\ref{fig:attrition} trace the path to the two analysis samples. We drop one store flagged out of mission by the platform, keep one representative listing per (store, normalised title) because many merchants publish colour or format variants as separate products (19{,}245 non-representative listings), and remove eleven \emph{mirror domains} --- language or top-level-domain twins of the same business, detected by domain stem or by a title overlap of at least 90\% of the smaller catalogue --- which would double-count assortments and manufacture within-firm ``cross-merchant'' price pairs. The \textbf{assortment sample} has 370{,}054 listings in 1{,}557 stores and twenty categories. The \textbf{price sample} further requires a Canadian-dollar store and listing, a positive posted price not ``on request'' and at most 50{,}000~CAD (9{,}635 listings have no posted price, 729 are in foreign currency, 78 exceed the cap): 359{,}612 listings. Within the assortment sample, 13.1\% of listings are marked sold out by the merchant, 8.7\% carry a strike-through price, 27.8\% have several variants, 76.7\% carry a merchant SKU and 75.6\% a brand field. | |
| 8 | 5 | |
| 9 | −We obtain data from \ldots{} Our sample spans the period from January~1990 to December~2023, covering \ldots{} We exclude \ldots{} consistent with standard practice in the literature \citep{fama1993common}. | |
| 6 | +\input{tables/sample_flow} | |
| 10 | 7 | |
| 11 | −\subsection{Variable Construction} | |
| 12 | −\label{subsec:variables} | |
| 8 | +\subsection{Merchant geography}\label{sec:data_geo} | |
| 9 | +The platform assigns a region from the discovery source (regional directories), from region mentions on the site, or from a map of forward sortation areas (FSA, the first three characters of the postal code); coordinates are FSA centroids obtained from a public geocoder, never civic addresses. At the snapshot 346 of the 1{,}569 live stores had no region, including the four largest catalogues (a book retailer, a souvenir printer, a trailer-parts seller and a pet-supply retailer). We recovered regions from official geography: for urban FSAs the sector label is matched to the MRNF municipality table (393 stores, 95.9\% agreement with the point-in-polygon of the centroid where both exist), then the platform's own label is kept (865 stores), then the FSA centroid is located in the MRNF administrative-region polygons (82 stores). The order was amended after preregistration (A-001): a first attempt with the Statistics Canada 2021 economic-region layer produced 229 apparent conflicts between platform labels and polygons, of which 99 were a spelling artefact and most of the rest the Brome-Missisquoi/Haute-Yamaska transfer from Montérégie to Estrie that the 2021 layer predates; with the current MRNF layer the platform label and the centroid agree for 89.1\% of stores, and the remaining disagreements (30 after the final rule) are rural FSAs whose centroids fall near a border, which is why the platform label is preferred to the centroid. After recovery, 229 stores (71{,}692 listings, 19\% of the assortment sample) remain without a location; they come mainly from gift directories and search sweeps, are reported as a separate ``Inconnue'' row in descriptive tables, and are excluded from all regional inference. One further caveat: the telephone field of 250 stores is the switchboard of the certification directory that listed them (212 carry the number of Les Produits du Québec); telephones are therefore never used for geography. Table~\ref{tab:region_summary} summarises the regional sample. | |
| 13 | 10 | |
| 14 | −Our dependent variable is \ldots{} defined as: | |
| 15 | −% | |
| 16 | −\begin{equation} | |
| 17 | − r_{i,t} = \ln\!\left(\frac{P_{i,t}}{P_{i,t-1}}\right), | |
| 18 | − \label{eq:returns} | |
| 19 | −\end{equation} | |
| 20 | −% | |
| 21 | −where $P_{i,t}$ denotes the adjusted closing price of asset~$i$ at time~$t$. | |
| 11 | +\input{tables/region_summary} | |
| 22 | 12 | |
| 23 | −The main explanatory variables include \ldots{} | |
| 13 | +\subsection{Category labels: audit and classifier (Amendment A-002)}\label{sec:data_categories} | |
| 14 | +The platform assigns one of twenty categories by keyword rules on the merchant's product type, tags, title and option names. We audited these labels on a stratified random sample of 500 listings (25 per category) annotated independently by two large language models (claude-opus-5-5 and gpt-5.5) given the platform's own taxonomy definitions; the two annotators agree with each other on 94.8\% of listings but with the rule label on only 45.2\% and 44.6\% respectively, so the precision of the rule label against annotator consensus is 0.46 (Table~\ref{tab:cat_audit}). Errors are structured: the ``other'' bin is almost entirely resolvable into industrial, art or home goods; ``art'' absorbs industrial and craft supplies; beverages, alcohol, pets and outdoor goods are frequently mislabelled fashion or art when a merchant's tags are generic. Jewellery, garden and fashion are the most reliable rule labels. | |
| 24 | 15 | |
| 25 | −\subsection{Summary Statistics} | |
| 26 | −\label{subsec:summary_stats} | |
| 16 | +%%CLASSIFIER%% | |
| 27 | 17 | |
| 28 | −Table~\ref{tab:summary_stats} presents the descriptive statistics for our key variables. | |
| 18 | +\subsection{Cross-merchant matching: candidates and validation}\label{sec:data_matching} | |
| 19 | +Tier-A (shared SKU) and tier-B (same brand, identical title) candidates number 1{,}837 and 457 pairs in different stores, of which 1{,}683 and 381 have a comparable variant structure; fuzzy tier C adds 446 (324 comparable) and identical-title-only tier D 2{,}713. On a validation sample of 60 pairs per tier, two independent annotators agree on 87.5\% of pairs; among consensus pairs the share judged the same product is 0.92 for tier A, 0.90 for tier B, 0.77 for tier C and 0.17 for tier D (Table~\ref{tab:match_val}); restricted to comparable-variant pairs, tiers A and B reach 0.90 and 0.96. These are the precision figures we carry into the hedonic analysis; the tier-D figure confirms that identical titles across artisanal shops (``savon à la lavande'') are not the same product. | |
| 29 | 20 | |
| 30 | −\input{tables/summary_stats} | |
| 21 | +\input{tables/match_validation} | |
| 31 | 22 | |
| 32 | −Several patterns emerge from the summary statistics. First, \ldots{} Second, \ldots{} The standard deviations indicate \ldots{} | |
| 23 | +\subsection{External data}\label{sec:data_external} | |
| 24 | +Regional covariates and geography were downloaded from their official sources with a manifest of URLs, licences, vintages and checksums (Appendix~\ref{app:source}): ISQ population at 1 July 2025, ISQ business locations with employees in December 2025 (all industries, retail NAICS 44--45, manufacturing 31--33), ISQ disposable income per capita 2024 and regional GDP 2024, Statistics Canada Census 2021 land area and density, the MRNF administrative-region and municipality layers (version 2026-10) and the Statistics Canada 2021 FSA boundaries. No recent regional (as opposed to provincial) measure of household or business e-commerce adoption exists; the only regional split of household internet access is the 2012 ISQ survey, which we do not use. | |
| 25 | + | |
| 26 | +\subsection{Representativeness}\label{sec:data_repr} | |
| 27 | +Fabri-Ka's store counts track the regional business base closely in levels --- Spearman correlations with ISQ business locations of 0.91 (all), 0.89 (retail) and 0.93 (manufacturing), and 0.84 with population --- but coverage per thousand business locations varies six-fold across regions (1.8 to 11.1, coefficient of variation 0.43; Table~\ref{tab:external}), with the agri-food and craft regions covered by dedicated directories (Saguenay--Lac-Saint-Jean, Bas-Saint-Laurent, Gaspésie) over-represented relative to Montréal and Laval. This is a discovery artefact, not a feature of the regional economies, and it is the main threat to every regional comparison below; Section~\ref{sec:robust} re-estimates the results on stores discovered through certification and association sources only and without Montréal. | |
| 28 | + | |
| 29 | +\input{tables/external_coverage} | |
added
paper/sections/discussion.tex
+2 −0
@@ -0,0 +1,2 @@ | ||
| 1 | +\section{discussion}\label{sec:discussion} | |
| 2 | +TODO. | |
added
paper/sections/hypotheses.tex
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +\section{Conceptual framework and preregistered hypotheses}\label{sec:hyp} | |
| 2 | + | |
| 3 | +\paragraph{What a catalogue can and cannot reveal.} Let $s$ index merchants, $r(s)$ the administrative region of the merchant's business location, and $c$ one of twenty product categories. A listing is a product a merchant chooses to publish online at the snapshot date. A region's online assortment is therefore the union of the publication decisions of the merchants located there, filtered by the aggregator's discovery process. Three forces shape it: the regional production and retail base (which merchants exist), the decision to sell online and to publish a catalogue (adoption), and the aggregator's coverage (discovery). Only the first is of economic interest; the other two are nuisance and are the object of the representativeness audit. Because the unit is a listing and not a sale, an employee or a shipment, all quantities below are \emph{assortment} quantities, and because merchant location is not product origin, all regional statements concern merchants based in a region, not goods made there. | |
| 4 | + | |
| 5 | +\paragraph{Hypotheses.} The hypotheses, estimands, specifications, exclusions and decision rules were frozen in a preregistration document before any Phase~4 statistic was computed (SHA-256 recorded in the decision log; two amendments, A-001 on the region-assignment rule and A-002 on category labels, are reported in Section~\ref{sec:data} and were decided before seeing results under the amended definitions). | |
| 6 | + | |
| 7 | +\begin{description}[leftmargin=1.2em,style=nextline] | |
| 8 | +\item[H1 --- Regional specialization (confirmatory).] Assortment composition differs across regions beyond what a random allocation of merchants to regions would produce. Statistic: Cramér's $V$ of the region$\times$category table; null: 2{,}000 permutations of region labels across stores. Decision: $p<0.05$ (Holm-adjusted) under both merchant-equal and trimmed-listing weights. | |
| 9 | +\item[H2 --- Diversity and scale (confirmatory, low power).] Rarefied assortment diversity (entropy at fifteen stores) is associated with regional population. Statistic: Spearman $\rho$ with a bootstrap interval; supported if the interval excludes zero. | |
| 10 | +\item[H3 --- Concentration (confirmatory).] Catalogue sizes are highly skewed: Gini $\ge0.70$ and top-decile share $\ge0.50$, with bootstrap intervals. | |
| 11 | +\item[H4 --- Related diversification (exploratory).] Within-store entry into categories absent at baseline increases with relatedness density; region-level entries are counted and declared untestable if fewer than ten. | |
| 12 | +\item[H5 --- Price dispersion (confirmatory, two tests).] For matched products, posted prices are (T1) geographically structured --- joint wild-cluster-bootstrap test of region effects --- and (T2) competitively structured --- reseller versus producer effect. | |
| 13 | +\item[H6 --- Local-origin premium.] Declared \emph{untestable} ex ante: the source has no product-level provenance. A store-certification contrast is reported descriptively and labelled as not being H6. | |
| 14 | +\item[H7 --- Digital-assortment complexity (validation).] A region--category complexity indicator is reported only if it passes bootstrap rank stability ($\rho\ge0.8$), a store-reshuffle null, and a leave-one-region-out predictive check; otherwise it is reported as unstable and excluded from the index family. | |
| 15 | +\end{description} | |
| 16 | + | |
| 17 | +The confirmatory family is $\{$H1, H2, H3, H5-T1, H5-T2$\}$ with Holm step-down at $\alpha=0.05$; raw $p$-values are also reported. Every causal reading is excluded by design: the data are a single cross-section of posted listings from a non-probability discovery process. | |
added
paper/sections/institutional.tex
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +\section{Institutional setting and the Fabri-Ka platform}\label{sec:setting} | |
| 2 | + | |
| 3 | +\paragraph{Quebec's administrative regions.} Quebec is divided into seventeen administrative regions (\emph{régions administratives}) defined by provincial decree and used by the Institut de la statistique du Québec (ISQ) for regional accounts, population estimates and business counts \citep{isq2024,quebec_regions}. Statistics Canada's economic regions for Quebec are by construction groupings of complete census divisions that coincide with the administrative regions \citep{statcan_br}; two census divisions (Brome-Missisquoi and La Haute-Yamaska) were transferred from Montérégie to Estrie on 28 July 2021, so Statistics Canada series on 2021 economic-region boundaries predate that change for those two regions while ISQ series and the current boundary files of the Ministère des Ressources naturelles et des Forêts (MRNF) reflect it. We use the MRNF boundaries (version 2026-10) and ISQ regional series throughout, and Statistics Canada series only where ISQ has no equivalent (Section~\ref{sec:data_external}). | |
| 4 | + | |
| 5 | +\paragraph{Fabri-Ka.} Fabri-Ka (\url{https://www.fabri-ka.com}) is a product-discovery aggregator built and operated by the author. It does not sell: each listing links to the merchant's own storefront. Its construction has two stages that matter for interpretation. First, a \emph{discovery} pipeline assembles a registry of Quebec online stores from certification registries (Aliments du Québec, Les Produits du Québec, the CARTV organic register), sector associations (maple, cheese, cider, wine, distillery and microbrewery associations, the Conseil des métiers d'art), regional agri-food brands (Zone boréale, Créateurs de saveurs, Croquez Outaouais, Gaspésie gourmande and others), marketplaces and gift directories, and category-by-region search sweeps; each domain is visited, its e-commerce platform detected, its public catalogue endpoint tested, and Quebec location signals (postal codes, telephone area codes, wording) recorded. Second, \emph{connectors} by e-commerce platform --- Shopify's \texttt{products.json}, the WooCommerce Store API, Wix, Squarespace, Square, Votresite and a generic JSON-LD reader --- synchronise each store's public catalogue about every six hours, upserting listings by a content hash and retiring listings after two consecutive misses. Compliance per source (robots directives, terms, public endpoints only, removal on request) is documented in the platform's own compliance file and summarised in Appendix~\ref{app:source}. | |
| 6 | + | |
| 7 | +Three features of the platform shape what the data can and cannot say. (i) The unit is a merchant's \emph{published listing} at a point in time; the database keeps only the current price per listing, so there is no price history. (ii) Each store carries an \emph{origin class} assigned from the discovery source (a maple producer listed by the producers' association is class A with confidence 0.95; a retailer listed by Les Produits du Québec is class C) and adjusted by site wording, with the textual evidence retained; this is a store-level prior, not product-level provenance. Les Produits du Québec's marks --- ``Produit du Québec'' (at least 85\% of direct production and manufacturing costs incurred in Quebec plus final substantial transformation), ``Fabriqué au Québec'' and ``Conçu au Québec'' --- and the Aliments du Québec marks \citep{produitsqc,quebec_aliments,alimentsqc} are the only admissible origin evidence, and they attach to enterprises, not to listings. (iii) Discovery favours certified, association-member and search-indexed businesses and deliberately topped up Montréal through dedicated sources; it under-represents artisans without a web shop, industrial manufacturers selling direct, Etsy sellers (excluded by Etsy's terms) and Nord-du-Québec. The platform's public counters on 1 November 2026 reported 390{,}108 products, 1{,}569 live stores and 17 regions; the research sample derived below is smaller, and Section~\ref{sec:data} traces every exclusion. | |
modified
paper/sections/introduction.tex
+2 −14
@@ -1,14 +1,2 @@ | ||
| 1 | −% ============================================================================ | |
| 2 | −\section{Introduction} | |
| 3 | −\label{sec:introduction} | |
| 4 | −% ============================================================================ | |
| 5 | − | |
| 6 | −The question of \ldots{} has attracted considerable attention in the finance and economics literature. Despite the growing body of evidence, the mechanisms underlying \ldots{} remain poorly understood. This paper contributes to this line of research by \ldots{} | |
| 7 | − | |
| 8 | −The motivation for this study is threefold. First, \ldots{} Second, \ldots{} Third, \ldots{} | |
| 9 | − | |
| 10 | −Our main findings can be summarized as follows. We show that \ldots{} Furthermore, we document that \ldots{} Finally, our robustness checks confirm that \ldots{} | |
| 11 | − | |
| 12 | −This paper relates to several strands of the literature. First, it contributes to the literature on \ldots{} \citep{fama1993common}. Second, it builds on the work of \citet{bollerslev1986generalized} by \ldots{} Third, our methodological approach draws on \citet{newey1987simple}. | |
| 13 | − | |
| 14 | −The remainder of the paper is organized as follows. Section~\ref{sec:literature} reviews the related literature. Section~\ref{sec:data} describes the data. Section~\ref{sec:methodology} presents the empirical methodology. Section~\ref{sec:results} reports the main results. Section~\ref{sec:robustness} provides robustness checks. Section~\ref{sec:conclusion} concludes. | |
| 1 | +\section{introduction}\label{sec:introduction} | |
| 2 | +TODO. | |
modified
paper/sections/literature.tex
+10 −14
@@ -1,21 +1,17 @@ | ||
| 1 | −% ============================================================================ | |
| 2 | −\section{Literature Review} | |
| 3 | −\label{sec:literature} | |
| 4 | −% ============================================================================ | |
| 1 | +\section{Related literature and positioning}\label{sec:lit} | |
| 5 | 2 | |
| 6 | −This section reviews the main theoretical and empirical contributions related to our research question. | |
| 3 | +The paper draws on six literatures. We summarise what each establishes, and where the present exercise sits relative to its closest empirical precedents. Every reference below was verified against Crossref or the publisher's page before citation; one critique of the method of reflections that could not be verified is not cited. | |
| 7 | 4 | |
| 8 | −\subsection{Theoretical Framework} | |
| 9 | −\label{subsec:theory} | |
| 5 | +\paragraph{Economic geography and regional specialization.} Industries are localised far beyond a random allocation of plants across space \citep{ellison1997,duranton2005,mori2005}, with heterogeneity across industries and scales \citep{rosenthal2001} that also holds in Canadian plant microdata \citep{baldwin2008}. The micro-foundations are sharing, matching and learning \citep{marshall1920,duranton2004}; density elasticities of productivity are modest and demanding to identify \citep{rosenthal2004,combes2015,duranton2020}. Whether local specialisation or diversity favours growth remains contested \citep{glaeser1992,henderson1995}, and the related/unrelated-variety decomposition of entropy is the modern operationalisation \citep{frenken2007,content2016}. Market size and love of variety \citep{krugman1980,krugman1991,fujita1999} and the skill-ordered comparative advantage of cities \citep{davis2020} motivate a link between regional scale and assortment breadth; urban scaling shows that the number of distinct business types grows roughly with the logarithm of population and that activities appear in a nested order across city sizes \citep{bettencourt2007,youn2016,hong2020}. For Quebec and Canada, \citet{polese2004,polese2006} and \citet{shearmur2007,shearmur2008} document a persistent centre--periphery gradient in employment location. None of this work uses the catalogues of online merchants as the object of measurement, and none covers Quebec's seventeen administrative regions in the 2020s. Our revealed assortment advantage (RAA) is the Balassa logic \citep{balassa1965} applied to listing counts, with the dartboard idea of \citet{ellison1997} supplying the null: a random allocation of \emph{merchants}, not listings, to regions. | |
| 10 | 6 | |
| 11 | −The theoretical foundations of \ldots{} were established by \citet{cochrane2005asset}. In their framework, \ldots{} | |
| 7 | +\paragraph{Economic complexity, relatedness and diversification.} The Balassa index is skewed and unstable at the top, and symmetric or normalised variants exist \citep{hinloopen2001,yu2009,laursen2015}. The product space \citep{hidalgo2007} and the principle of relatedness \citep{hidalgo2018,boschma2017} are robust regularities: regions diversify into activities related to what they already do, documented with industries \citep{neffke2011}, patents \citep{kogler2013,rigby2015,balland2017}, occupations \citep{muneepeerakul2013} and firms \citep{gao2018}, and combined with complexity in the smart-specialisation framework \citep{balland2019}. Economic complexity and fitness indices summarise bipartite region--activity matrices \citep{hidalgo2009,hausmann2011,hausmann2014,tacchella2012,cristelli2013,servedio2018}; the ECI is formally a spectral clustering of a region-similarity matrix \citep{mealy2019}, rankings are sensitive to algorithm and sample \citep{morrison2017,sciarra2020}, and structure in bipartite matrices must be tested against degree-preserving null models \citep{saracco2015}; see \citet{hidalgo2021} and \citet{balland2022} for surveys. All applications we could verify use production-side bases (exports, patents, industries, occupations, firms). Nothing is known about the behaviour of these tools on a $17\times20$ matrix built from online listings, which is why we treat the complexity indicator as an exercise in validation rather than as a ranking (Section~\ref{sec:results_h7}). | |
| 12 | 8 | |
| 13 | −\subsection{Empirical Evidence} | |
| 14 | −\label{subsec:empirical_evidence} | |
| 9 | +\paragraph{Online retail, the long tail and the geography of online demand.} Online variety created consumer surplus and shifted sales toward niche products \citep{brynjolfsson2003,brynjolfsson2011,zentner2013,waldfogel2017,anderson2006}, but hits remain dominant and the tail is thin in revenue \citep{elberse2008,tan2017}; search-cost theory predicts both long tails and superstars \citep{barisaac2012}. E-commerce reallocates retail share toward large firms and platform owners \citep{goldmanis2010,zhu2018,einav2016}. The geography of online demand is systematic: consumers buy more online where local supply is thin and when they are preference minorities \citep{forman2009,choi2011,brynjolfsson2009}, demand heterogeneity across markets limits the value of online variety for products with local tastes \citep{quan2018}, distance still matters online \citep{hortacsu2009,blum2006,lendle2016}, gains are larger for remote consumers \citep{dolfen2023,couture2021}, and e-commerce changes relative prices across space \citep{jo2019,lieber2012,goldfarb2019}. Platform design shapes dispersion \citep{dinerstein2018,ellison2009,baye2004,baye2006,brynjolfsson2000,clay2001}. This stream is demand- or transaction-based. The supply-side question --- what independent local merchants put online and how it varies across the regions of one province --- is, to our knowledge, not addressed; \citet{quan2018} and \citet{forman2009} are the closest mirrors, and our data cannot say whether regional assortments reflect regional tastes. | |
| 15 | 10 | |
| 16 | −A growing body of empirical literature examines \ldots{} \citet{fama1993common} document that \ldots{} More recently, \citet{petersen2009estimating} show that \ldots{} | |
| 11 | +\paragraph{Regional inequality and digital adoption.} Firms in large cities adopted the commercial internet first \citep{forman2005}; the wage gains from internet investment accrued to already-advantaged places \citep{forman2012,sinai2004,forman2018}; broadband effects on rural growth are positive but modest and hard to identify \citep{kolko2012,whitacre2014,haller2015,haller2019}; adoption and usage divides differ \citep{goldfarb2008}; Canadian household divides are documented with the Canadian Internet Use Survey \citep{haight2014,statcan_cius,statcan2023cius} and business digital adoption with the SDTIU \citep{statcan_sdtiu}; the OECD measurement framework is \citet{oecd2019} and the Quebec programme is \citet{atn_netendances}. We found no peer-reviewed study of regional e-commerce \emph{supply} in Canada or Quebec; the Quebec-specific evidence is grey literature \citep{isq2024}, and the paper is explicit that this stream is the thinnest in verified academic sources. | |
| 17 | 12 | |
| 18 | −\subsection{Contribution to the Literature} | |
| 19 | −\label{subsec:contribution} | |
| 13 | +\paragraph{Product matching, hedonic prices and spatial price dispersion.} Record linkage rests on \citet{fellegi1969} and \citet{christen2012}. Scraped online prices are a valid but selected source: online and offline prices coincide for most multi-channel retailers \citep{cavallo2017}, scraped data have specific frequency and stockout properties \citep{cavallo2018,cavallo2023}, and the Billion Prices Project set the standards \citep{cavallo2016}; online price-setting and cross-border integration are documented by \citet{gorodnichenko2017,gorodnichenko2018,cavallo2014}; web-scraped prices now enter official indices \citep{konny2022}. Dispersion is mostly relative and persistent \citep{kaplan2015,kaplan2019}; US chains price almost uniformly across locations \citep{dellavigna2019}; spatial price gaps grow with distance \citep{atkin2015}; variety and prices differ across cities \citep{handbury2015,handbury2021}; hedonic theory and practice follow \citet{rosen1974,pakes2003,bajari2005}. Cross-merchant dispersion among many \emph{independent} small online stores in one province, with product fixed effects from matched titles and SKUs, has no direct precedent; the near-collinearity of merchant and region effects is specific to our setting. | |
| 20 | 14 | |
| 21 | −Our paper departs from the existing literature in several important ways. First, \ldots{} Second, \ldots{} Third, \ldots{} | |
| 15 | +\paragraph{Measurement error and representativeness of non-probability data.} For non-probability big data the error is governed by the data-defect correlation rather than by sample size \citep{meng2018,bradley2021,lazer2014}; inference requires explicit selection models \citep{elliott2017,buelens2018,heckman1979,groves2006,little2019}, and statistical agencies are explicit about the conditions \citep{daas2015,beaumont2020,rao2021}; measurement error in regressors and outcomes \citep{hausman2001,bound2001} and the modifiable areal unit problem \citep{briant2010} are additional threats. We take these literatures literally: Section~\ref{sec:data} reports a data-quality ledger, a category-label audit, a matching validation and a representativeness audit against Statistics Canada and ISQ counts. | |
| 16 | + | |
| 17 | +\paragraph{What is and is not new here.} The contribution is a documented, validated supply-side dataset of online product listings of Quebec-based merchants by administrative region; RAA and diversity measures with merchant-count-preserving permutation nulls and merchant-weighted variants; a region--category bipartite indicator whose eigenvector interpretation is tested against bootstraps and null models and reported as a family of components rather than a headline score; cross-merchant dispersion of matched products among independent stores with explicit collinearity diagnostics; and a representativeness audit of an aggregator against business counts. No method is new. We do not provide evidence on agglomeration economies, on diversification over time, on sales concentration, on gravity in online trade or on welfare, and we never call the indicator a production or economic complexity index: without verified product origin it describes observed online catalogues only. | |
deleted
paper/sections/methodology.tex
+0 −44
@@ -1,44 +0,0 @@ | ||
| 1 | −% ============================================================================ | |
| 2 | −\section{Methodology} | |
| 3 | −\label{sec:methodology} | |
| 4 | −% ============================================================================ | |
| 5 | − | |
| 6 | −\subsection{Empirical Model} | |
| 7 | −\label{subsec:model} | |
| 8 | − | |
| 9 | −To test our hypotheses, we estimate the following baseline specification: | |
| 10 | −% | |
| 11 | −\begin{equation} | |
| 12 | − y_{i,t} = \alpha + \beta_1 X_{i,t} + \beta_2 Z_{i,t} + \gamma_i + \delta_t + \varepsilon_{i,t}, | |
| 13 | − \label{eq:baseline} | |
| 14 | −\end{equation} | |
| 15 | −% | |
| 16 | −where $y_{i,t}$ is the dependent variable for firm~$i$ in period~$t$, $X_{i,t}$ is the main variable of interest, $Z_{i,t}$ is a vector of control variables, $\gamma_i$ represents firm fixed effects, $\delta_t$ captures time fixed effects, and $\varepsilon_{i,t}$ is the error term. | |
| 17 | − | |
| 18 | −Our coefficient of interest is $\beta_1$. Under the null hypothesis that \ldots{}, we expect $\beta_1 = 0$. A positive (negative) estimate of $\beta_1$ would indicate \ldots{} | |
| 19 | − | |
| 20 | −\subsection{Identification Strategy} | |
| 21 | −\label{subsec:identification} | |
| 22 | − | |
| 23 | −The main identification challenge arises from \ldots{} To address potential endogeneity concerns, we employ \ldots{} | |
| 24 | − | |
| 25 | −We cluster standard errors at the firm level to account for within-firm serial correlation, following \citet{petersen2009estimating}. | |
| 26 | − | |
| 27 | −\subsection{Extended Specifications} | |
| 28 | −\label{subsec:extended} | |
| 29 | − | |
| 30 | −We extend the baseline model in several directions. First, we allow for nonlinear effects: | |
| 31 | −% | |
| 32 | −\begin{equation} | |
| 33 | − y_{i,t} = \alpha + \beta_1 X_{i,t} + \beta_2 X_{i,t}^2 + \boldsymbol{\beta}_3' \mathbf{Z}_{i,t} + \gamma_i + \delta_t + \varepsilon_{i,t}. | |
| 34 | − \label{eq:nonlinear} | |
| 35 | −\end{equation} | |
| 36 | − | |
| 37 | −Second, we examine heterogeneous effects by interacting $X_{i,t}$ with \ldots{}: | |
| 38 | −% | |
| 39 | −\begin{equation} | |
| 40 | − y_{i,t} = \alpha + \beta_1 X_{i,t} + \beta_2 D_{i,t} + \beta_3 (X_{i,t} \times D_{i,t}) + \boldsymbol{\beta}_4' \mathbf{Z}_{i,t} + \gamma_i + \delta_t + \varepsilon_{i,t}, | |
| 41 | − \label{eq:interaction} | |
| 42 | −\end{equation} | |
| 43 | −% | |
| 44 | −where $D_{i,t}$ is an indicator variable equal to one if \ldots{} The coefficient $\beta_3$ captures the differential effect of $X$ for \ldots{} | |
added
paper/sections/methods.tex
+31 −0
@@ -0,0 +1,31 @@ | ||
| 1 | +\section{Empirical methods and identification limits}\label{sec:methods} | |
| 2 | + | |
| 3 | +\subsection{Weighting schemes} | |
| 4 | +Catalogue sizes are extremely skewed (Section~\ref{sec:results_h3}): the ten largest stores hold more than a third of all listings. Every regional statistic is therefore computed under three schemes. Under \textbf{L} each listing counts one. Under \textbf{M} (merchant-equal, the primary scheme for regional composition) each listing counts $1/n_s$, so that every store contributes one unit regardless of catalogue size. Under \textbf{T} listings count one after dropping the one per cent largest stores. The schemes answer different questions --- L describes what a visitor sees, M describes the population of merchants --- and we report where they disagree. | |
| 5 | + | |
| 6 | +\subsection{Revealed assortment advantage and the merchant-reshuffle null} | |
| 7 | +For region $r$ and category $c$ let $N_{rc}$ be the (weighted) count of listings. The revealed assortment advantage is | |
| 8 | +\begin{equation} | |
| 9 | +\mathrm{RAA}_{rc}=\frac{N_{rc}/\sum_{c}N_{rc}}{\sum_{r}N_{rc}/\sum_{rc}N_{rc}}, | |
| 10 | +\end{equation} | |
| 11 | +the Balassa index on listings. Unlike export RCA it does not measure production, trade or competitiveness; it measures what the merchants of a region publish relative to what Quebec merchants publish overall. Dependence between region and category is summarised by Cramér's $V$ of the $R\times C$ table and tested against a null that reshuffles region labels across \emph{stores} while keeping every store's catalogue and every region's number of stores fixed; the $p$-value is the share of 2{,}000 permutations with $V$ at least as large as observed. This is the dartboard logic of \citet{ellison1997} transposed to merchants: a region may look specialised simply because one large merchant is located there, and the null absorbs this. Uncertainty for each $\mathrm{RAA}_{rc}$ comes from 2{,}000 bootstrap resamples of stores; because resampling with replacement duplicates stores and biases $V$ upward, bootstrap intervals are reported for the cells and not for $V$, whose inference is by permutation. | |
| 12 | + | |
| 13 | +\subsection{Diversity and concentration} | |
| 14 | +Regional diversity is the normalised Shannon entropy of category shares, $\tilde H_r=-\sum_c p_{rc}\ln p_{rc}/\ln 20$, the effective number of categories $\exp(H_r)$ and the Herfindahl index, under L and M. Because entropy increases mechanically with the number of merchants, the primary H2 outcome is the \emph{rarefied} entropy: the mean over 2{,}000 draws of fifteen stores without replacement, which puts regions with 15 and 235 stores on the same footing; regions with fewer than fifteen stores are excluded from that statistic. Concentration is the Gini coefficient of listings across stores, top-percentile shares, the rank--size slope, and a discrete power-law fit with $x_{\min}$ chosen by KS minimisation compared with a log-normal on the same tail by a Vuong likelihood-ratio test \citep[following][]{ellison1997}. | |
| 15 | + | |
| 16 | +\subsection{Region--category network and complexity indicator} | |
| 17 | +Let $M_{rc}=\mathbf 1[\mathrm{RAA}_{rc}\ge1]$ under scheme M. Diversity is the row sum, ubiquity the column sum; the ECI is the second eigenvector of $\tilde M=D_r^{-1}MD_c^{-1}M^{\top}$ \citep{hidalgo2009,mealy2019}, with the sign fixed so that it correlates positively with diversity; the fitness index is the fixed point of the non-linear iteration of \citet{tacchella2012}. Category relatedness is the Hidalgo-type proximity $\phi_{cc'}=\min\{P(c\mid c'),P(c'\mid c)\}$ computed from co-occurrence of categories across \emph{stores}, and communities are obtained by Louvain over 200 seeds. Validation follows the preregistration: (V1) the Spearman correlation between the full-sample ECI and 500 store-bootstrap ECIs; (V2) the second eigenvalue of $\tilde M$ and the spectral radius of the bipartite adjacency matrix against 2{,}000 store-level region reshuffles \citep[a merchant-preserving analogue of the null models in][]{saracco2015}; (V3) the leave-one-region-out predictive correlation of the ECI with regional population, business locations and income; (V4) the normalised mutual information between community partitions across seeds. | |
| 18 | + | |
| 19 | +\subsection{Matched products and hedonic dispersion} | |
| 20 | +No GTIN exists in the source. Cross-merchant identity is inferred in tiers --- A: shared merchant SKU and token-set title similarity $\ge70$; B: same normalised brand and identical normalised title; C: same brand, same category and token-sort similarity $\ge90$; D: identical title without brand --- restricted to listings in the price sample and to pairs of different stores that are not mirror domains of one business. Tiers A and B form the primary sample and C a sensitivity; D is never used for inference. Precision is estimated on a labelled sample of 240 pairs (Section~\ref{sec:data}). For listing $i$ in match group $g$ sold by store $m$ in region $r(m)$, | |
| 21 | +\begin{equation} | |
| 22 | +\ln P_{i}=\alpha_{g}+\gamma_{r(m)}+\delta_{\mathrm{platform}(m)}+\beta_1\ln n_m+\beta_2\,\mathrm{Reseller}_m+\beta_3\,\mathrm{Certified}_m+\varepsilon_{i}, | |
| 23 | +\label{eq:hedonic} | |
| 24 | +\end{equation} | |
| 25 | +estimated by within-group least squares with standard errors clustered by store. Because each store belongs to one region, a store fixed effect cannot be included alongside $\gamma_r$: region effects are identified only through match groups that span regions, which is why the sample size in \emph{stores} and the number of regions with at least five matched stores are reported, and why inference uses a wild cluster bootstrap (Rademacher weights by store, 9{,}999 draws, restricted residuals) for the joint test of $\gamma_r$ and for $\beta_2$. Prices are posted minimum-variant prices at the snapshot; shipping, taxes and transactions are not observed. | |
| 26 | + | |
| 27 | +\subsection{Dynamics} | |
| 28 | +The only temporal margin is listing entry and exit after store onboarding ended (24 August 2026). Weekly presence of each (store, category) pair is reconstructed from first and last observation dates; relatedness density is computed from the baseline weeks 36--39 and entry is measured over weeks 40--44, a temporal holdout that prevents the outcome from feeding the regressor. A linear probability model with store and category fixed effects and store-clustered standard errors is compared with a placebo that relabels categories in the proximity matrix. The exercise is exploratory by preregistration. | |
| 29 | + | |
| 30 | +\subsection{External data and the representativeness audit} | |
| 31 | +Regional covariates come from the ISQ (population at 1 July 2025, business locations with employees in December 2025 by NAICS, disposable income per capita 2024, regional GDP 2024) and from Statistics Canada (Census 2021 land area and density on 2021 economic-region boundaries); vintages and concordance issues are recorded in the external-data manifest. Coverage is the number of Fabri-Ka stores per thousand business locations and per 100{,}000 inhabitants; the audit also tabulates discovery source and platform by region and category mix by platform, following the data-defect logic of \citet{meng2018}. | |
added
paper/sections/reproducibility.tex
+2 −0
@@ -0,0 +1,2 @@ | ||
| 1 | +\section*{Reproducibility statement}\addcontentsline{toc}{section}{Reproducibility statement} | |
| 2 | +The analysis is a deterministic pipeline (seed 20261101) run from a single \texttt{Makefile}: \texttt{make ingest clean match validate features models robust tables figures paper}, with \texttt{make test} running the invariant tests on the cleaned panel. The raw snapshot is an owner-authorised, read-only copy of the Fabri-Ka database taken with \texttt{sqlite3.Connection.backup()} on 2026-11-01T17:38:30Z (SHA-256 \texttt{8d963ba9\ldots98fb}); the two tables containing personal data were dropped on the production node before transfer and nothing in production was modified. The snapshot is not redistributed (it contains merchant catalogues that belong to the merchants); the data dictionary, the sample-flow table, every intermediate aggregate, the labelled audit samples, the external-data manifest with URLs, licences, vintages and checksums, the run ledger with every API call and its cost, and the SHA-256 of every output are in the repository. LLM annotations (two independent models for the category audit and the matching validation; one model for the classifier training labels) are stored as CSV files so that the pipeline runs without API access; no empirical result depends on a live model call. Dependencies are pinned in \texttt{pyproject.toml} (Python 3.12). | |
modified
paper/sections/results.tex
+2 −27
@@ -1,27 +1,2 @@ | ||
| 1 | −% ============================================================================ | |
| 2 | −\section{Results} | |
| 3 | −\label{sec:results} | |
| 4 | −% ============================================================================ | |
| 5 | − | |
| 6 | −\subsection{Baseline Results} | |
| 7 | −\label{subsec:baseline_results} | |
| 8 | − | |
| 9 | −Table~\ref{tab:regression} presents the estimation results of our baseline model specified in Equation~\eqref{eq:baseline}. Column~(1) reports the OLS estimates without fixed effects. Columns~(2)--(4) progressively add firm and time fixed effects and the full set of controls. | |
| 10 | − | |
| 11 | −\input{tables/regression} | |
| 12 | − | |
| 13 | −The coefficient on $X_{i,t}$ is positive and statistically significant at the 1\% level across all specifications. The point estimate in our preferred specification (Column~4) implies that a one-standard-deviation increase in $X$ is associated with a \ldots{} increase in $y$. | |
| 14 | − | |
| 15 | −\subsection{Graphical Evidence} | |
| 16 | −\label{subsec:graphical} | |
| 17 | − | |
| 18 | −Figure~\ref{fig:main_result} provides graphical evidence supporting our main findings. | |
| 19 | − | |
| 20 | −\input{figures/main_figure} | |
| 21 | − | |
| 22 | −The figure illustrates \ldots{} The upward trend in \ldots{} is consistent with the regression estimates reported in Table~\ref{tab:regression}. | |
| 23 | − | |
| 24 | −\subsection{Sub-sample Analysis} | |
| 25 | −\label{subsec:subsample} | |
| 26 | − | |
| 27 | −To explore potential heterogeneity, we re-estimate Equation~\eqref{eq:baseline} across sub-samples defined by \ldots{} The results, available in Table~\ref{tab:app_subsample} of Appendix~\ref{app:tables}, indicate that \ldots{} | |
| 1 | +\section{results}\label{sec:results} | |
| 2 | +TODO. | |
modified
paper/sections/robustness.tex
+2 −26
@@ -1,26 +1,2 @@ | ||
| 1 | −% ============================================================================ | |
| 2 | −\section{Robustness Checks} | |
| 3 | −\label{sec:robustness} | |
| 4 | −% ============================================================================ | |
| 5 | − | |
| 6 | −We conduct several robustness tests to assess the sensitivity of our baseline results. | |
| 7 | − | |
| 8 | −\subsection{Alternative Variable Definitions} | |
| 9 | −\label{subsec:alt_definitions} | |
| 10 | − | |
| 11 | −We first verify that our results are not driven by the specific construction of the dependent variable. Table~\ref{tab:app_robustness} in Appendix~\ref{app:tables} reports estimates using \ldots{} as alternative measures. The coefficient on $X_{i,t}$ remains statistically significant and economically meaningful across all specifications. | |
| 12 | − | |
| 13 | −\subsection{Alternative Estimation Methods} | |
| 14 | −\label{subsec:alt_estimation} | |
| 15 | − | |
| 16 | −We re-estimate the model using \ldots{} to address potential concerns about \ldots{} The results are qualitatively unchanged. | |
| 17 | − | |
| 18 | −\subsection{Placebo Tests} | |
| 19 | −\label{subsec:placebo} | |
| 20 | − | |
| 21 | −As a falsification exercise, we replace the main explanatory variable with \ldots{} If our baseline results reflect a genuine economic relationship rather than spurious correlation, we expect the placebo coefficients to be statistically insignificant. Consistent with this expectation, none of the placebo estimates are significant at conventional levels. | |
| 22 | − | |
| 23 | −\subsection{Sensitivity to Sample Period} | |
| 24 | −\label{subsec:sample_period} | |
| 25 | − | |
| 26 | −We examine whether our results are sensitive to the sample period by \ldots{} The estimates remain stable across sub-periods, suggesting that our findings are not driven by a specific time window. | |
| 1 | +\section{robustness}\label{sec:robustness} | |
| 2 | +TODO. | |
modified
paper/tables/category_audit.tex
+1 −1
@@ -24,5 +24,5 @@ Industrial & 24 & 0.42 \\ | ||
| 24 | 24 | Other & 25 & 0.00 \\ |
| 25 | 25 | \textbf{All} & 474 & \textbf{0.46} \\ |
| 26 | 26 | \bottomrule\end{tabular} |
| 27 | −\begin{minipage}{0.95\textwidth}\vspace{2pt}\footnotesize\textit{Notes:} 25 random listings per category annotated independently by two LLMs (claude-opus-5-5, gpt-5.5) with the platform's taxonomy; agreement rule vs A 0.45, rule vs B 0.45, A vs B 0.95; consensus exists for 95% of listings. Precision = share of consensus labels equal to the rule label. Disagreements listed in the replication package.\end{minipage} | |
| 27 | +\begin{minipage}{0.95\textwidth}\vspace{2pt}\footnotesize\textit{Notes:} 25 random listings per category annotated independently by two LLMs (claude-opus-5-5, gpt-5.5) with the platform's taxonomy; agreement rule vs A 0.45, rule vs B 0.45, A vs B 0.95; consensus exists for 95\% of listings. Precision = share of consensus labels equal to the rule label. Disagreements listed in the replication package.\end{minipage} | |
| 28 | 28 | \end{table} |
modified
paper/tables/match_validation.tex
+1 −1
@@ -7,5 +7,5 @@ B & 54 & 0.90 & 0.00 & 0.96 \\ | ||
| 7 | 7 | C & 52 & 0.77 & 0.10 & 0.77 \\ |
| 8 | 8 | D & 48 & 0.17 & 0.60 & 0.19 \\ |
| 9 | 9 | \bottomrule\end{tabular} |
| 10 | −\begin{minipage}{0.95\textwidth}\vspace{2pt}\footnotesize\textit{Notes:} Two independent LLM annotators (agreement 0.88; consensus on 88% of pairs). Tier A = shared merchant SKU; B = same brand and identical normalised title; C = same brand, fuzzy title; D = identical title without brand (never used for inference).\end{minipage} | |
| 10 | +\begin{minipage}{0.95\textwidth}\vspace{2pt}\footnotesize\textit{Notes:} Two independent LLM annotators (agreement 0.88; consensus on 88\% of pairs). Tier A = shared merchant SKU; B = same brand and identical normalised title; C = same brand, fuzzy title; D = identical title without brand (never used for inference).\end{minipage} | |
| 11 | 11 | \end{table} |
added
reports/hypothesis_registry_rule.md
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +# Hypothesis registry (rule category labelling) | |
| 2 | + | |
| 3 | +| id | statement | primary statistic | p raw | p Holm | status | | |
| 4 | +|---|---|---|---|---|---| | |
| 5 | +| H1 | Online assortment composition differs across regions beyond what merchant counts imply | Cramér's V (M) = 0.105, store-reshuffle p = 0.0005; (T) V = 0.159, p = 0.492; (L) p = 0.058 | 0.4923 | 0.9434 | **partially supported** | | |
| 6 | +| H2 | Regional diversity is associated with regional scale | Spearman rho(rarefied entropy, log population) = -0.20, 95% CI [-0.62; 0.34], n = 16 | 0.4717 | 0.9434 | **inconclusive** | | |
| 7 | +| H3 | Merchant catalogue sizes are highly skewed | Gini = 0.849 [0.811; 0.878]; top-10% share = 0.789 [0.730; 0.836] | | | **supported** | | |
| 8 | +| H4 | Related diversification (exploratory) | LPM density coefficient 0.111 (SE 0.050), placebo p = 0.010; 149 entries; 11 region-level entries | | | **exploratory: consistent with relatedness at the store level; region level untestable** | | |
| 9 | +| H5-T1 | Matched-product prices are geographically structured | Wald(14) = 164.4, wild-cluster-bootstrap p = 0.0045; 1064 groups, 143 stores, 9 regions with >= 5 stores | 0.0045 | 0.0135 | **supported** | | |
| 10 | +| H5-T2 | Matched-product prices are competitively structured (reseller vs producer) | reseller coefficient -0.116 (SE 0.026), wild-bootstrap p = 0.0012 | 0.0012 | 0.0048 | **supported** | | |
| 11 | +| H6 | Local-origin price premium | not estimated | | | **untestable (declared ex ante)** | | |
| 12 | +| H7 | Validated digital-assortment complexity indicator | ECI bootstrap rank stability median rho = 0.20 (criterion 0.8); lambda2 null p = 0.41; LOO predictive rho = 0.39 | | | **not validated: ECI unstable at 17 x 20; omitted from QDACI** | | |
| 13 | +| QDACI | Composite index | median agreement across weightings rho = 0.68 (rule 0.7); bootstrap rank stability 0.50 | | | **components published; composite withheld** | | |
| 14 | + | |
| 15 | +Notes: | |
| 16 | +- H1: Preregistered criterion required p < 0.05 under both merchant-equal and trimmed-listing weights; met for merchant-equal only. Specialization is a property of the merchant population, not of listing volumes. | |
| 17 | +- H2: CI includes zero and economically meaningful values in both directions; n = 16 regions. No association with business counts or density either (see table). | |
| 18 | +- H3: Both preregistered thresholds (Gini >= 0.70, top-10% >= 0.50) exceeded with CIs entirely above them; power-law tail favoured over log-normal on the tail. | |
| 19 | +- H4: Fewer than 10 region-level entries (preregistered untestability rule); store-level association over 5 weeks, no causal claim. | |
| 20 | +- H5-T1: Region effects identified only through groups spanning regions; posted prices, no shipping/taxes. | |
| 21 | +- H5-T2: Resellers/collectives post lower prices than producers for the same product. | |
| 22 | +- H6: No product-level provenance; a store-certification contrast is reported descriptively and is not H6. | |
| 23 | +- H7: Fitness and plain diversity are more stable but still below the criterion; the region-category matrix is not more nested than the store-reshuffle null. | |
| 24 | +- QDACI: Components describe observed online catalogues of Quebec-based merchants, not production or output. Complexity omitted because the ECI failed the preregistered stability criterion. | |
| \ No newline at end of file | ||
modified
research/decisions.md
+1 −0
@@ -10,3 +10,4 @@ | ||
| 10 | 10 | | D-006 | 2026-11-01 | Seed 20261101 for every bootstrap/permutation; outputs hashed in `logs/manifest.json` | reproducibility | |
| 11 | 11 | | D-007 | 2026-11-01 | Preregistration frozen: `research/preregistration.md` sha256 `b7cd3f177510af0b29d5a67c84f14562b068c36c789de1f5ea078ef4697e180b` | mandate Phase 3; frozen before any Phase 4 computation | |
| 12 | 12 | | A-001 | 2026-11-01 | **Amendment** to preregistration §2 / D-003: region assignment = municipality lookup of the FSA sector label (urban FSAs only, official MERN municipality table, current boundaries) > Fabri-Ka label (directory/site/FSA map) > point-in-polygon of the FSA centroid in the MERN administrative-region layer (V2026-10) > unknown. A point-in-polygon-first variant is the sensitivity sample. | After freezing we found (i) a first attempt with the StatCan 2021 economic-region layer produced spurious Estrie/Montérégie conflicts because it predates the 2021 transfer of Brome-Missisquoi and La Haute-Yamaska to Estrie — the official MERN layer and municipality table are used instead; (ii) 99 apparent conflicts were a double-hyphen spelling artefact; (iii) remaining disagreements (139) are 69 % rural FSAs whose centroids fall near borders, so the directory label is preferred when present. Region recovery adds 116 stores that had no label. | |
| 13 | +| A-002 | 2026-11-01 | **Amendment** to preregistration §2 (categories "used as labelled"): the 500-listing audit found the platform's keyword rules agree with annotator consensus on only 46 % of listings (two LLM annotators agree with each other on 95 %). A supervised classifier (OpenAI text-embedding-3-small + one-hot rule label, multinomial logistic regression) is trained on 6 481 listings labelled by claude-sonnet-5-5 and validated on the audit consensus sample (held out). **All category-based results (H1, H2, H4, H7, QDACI, robustness) are reported under both labellings; the model-based labelling becomes the primary one only if its audit accuracy exceeds the rule labelling by at least 20 points; otherwise the preregistered rule labelling stays primary.** | measurement validity; decided before seeing any result under the model labelling | | |
modified
src/clean/listings.py
+2 −2
@@ -18,7 +18,7 @@ Flags (all kept, so every analysis can state its own exclusion set): | ||
| 18 | 18 | from __future__ import annotations |
| 19 | 19 | import json, re, unicodedata |
| 20 | 20 | import numpy as np, pandas as pd |
| 21 | −from src.common import ROOT, P, get_logger, ledger, register_output, write_json | |
| 21 | +from src.common import ROOT, P, get_logger, ledger, register_output, write_json, BASE_PROC, BASE_EXP | |
| 22 | 22 | |
| 23 | 23 | log = get_logger("clean.listings") |
| 24 | 24 | PRICE_CAP = 50_000.0 |
@@ -93,7 +93,7 @@ def main(): | ||
| 93 | 93 | "price_quantiles_cad": {str(q): round(float(base.loc[base.f_analysis_price, "price"].quantile(q)), 2) for q in (0.001, 0.01, 0.1, 0.5, 0.9, 0.99, 0.999)}, |
| 94 | 94 | "top10_stores_share_of_assortment": round(float(a.store_id.value_counts().head(10).sum() / len(a)), 4), |
| 95 | 95 | } |
| 96 | − write_json(diag, ROOT / "experiments" / "sample_flow.json", "clean.listings") | |
| 96 | + write_json(diag, BASE_EXP / "sample_flow.json", "clean.listings") | |
| 97 | 97 | md = ["# Sample flow (attrition) — WP38", "", f"Snapshot 2026-11-01T17:38:30Z. Nothing is deleted; every step is a flag in `data/interim/listings_clean.parquet`.", "", |
| 98 | 98 | "| step | listings |", "|---|---:|"] + [f"| {k} | {v:,} |".replace(",", " ") for k, v in flow] + ["", |
| 99 | 99 | "## Assortment sample diagnostics", "```", json.dumps(diag["assortment_sample"], indent=1), "```", |
modified
src/clean/siblings.py
+3 −3
@@ -10,7 +10,7 @@ the store with the most listings (ties: alphabetical). Output: data/interim/sibl | ||
| 10 | 10 | from __future__ import annotations |
| 11 | 11 | import itertools, json, re |
| 12 | 12 | import pandas as pd, networkx as nx |
| 13 | −from src.common import ROOT, P, get_logger, ledger, register_output, write_json | |
| 13 | +from src.common import ROOT, P, get_logger, ledger, register_output, write_json, BASE_PROC, BASE_EXP | |
| 14 | 14 | |
| 15 | 15 | log = get_logger("clean.siblings") |
| 16 | 16 | |
@@ -59,12 +59,12 @@ def main(): | ||
| 59 | 59 | for s in comp: rows.append((gid, s, comp[0], s == comp[0], sizes.get(s, 0))) |
| 60 | 60 | sib = pd.DataFrame(rows, columns=["sibling_group", "id", "sibling_rep", "is_rep", "n_titles"]) |
| 61 | 61 | sib.to_parquet(P["interim"] / "sibling_stores.parquet", index=False); register_output(P["interim"] / "sibling_stores.parquet", "siblings") |
| 62 | − pd.DataFrame(reasons, columns=["a", "b", "reason", "overlap"]).to_csv(ROOT / "experiments" / "sibling_pairs.csv", index=False) | |
| 62 | + pd.DataFrame(reasons, columns=["a", "b", "reason", "overlap"]).to_csv(BASE_EXP / "sibling_pairs.csv", index=False) | |
| 63 | 63 | summ = {"sibling_groups": int(sib.sibling_group.nunique()) if len(sib) else 0, "stores_in_groups": len(sib), "non_rep_stores": int((~sib.is_rep).sum()) if len(sib) else 0, |
| 64 | 64 | "non_rep_listings": int(a[a.store_id.isin(sib[~sib.is_rep].id)].shape[0]) if len(sib) else 0, |
| 65 | 65 | "reasons": pd.Series([r[2] for r in reasons]).value_counts().to_dict(), |
| 66 | 66 | "largest_groups": sib.groupby("sibling_group").id.apply(list).map(lambda l: l[:4]).head(12).tolist()} |
| 67 | − write_json(summ, ROOT / "experiments" / "siblings.json", "siblings"); ledger("clean.siblings", **summ) | |
| 67 | + write_json(summ, BASE_EXP / "siblings.json", "siblings"); ledger("clean.siblings", **summ) | |
| 68 | 68 | log.info(json.dumps(summ, ensure_ascii=False)) |
| 69 | 69 | |
| 70 | 70 | |
modified
src/clean/stores.py
+2 −2
@@ -15,7 +15,7 @@ Region logic (documented in reports/phase2_data_audit.md): | ||
| 15 | 15 | from __future__ import annotations |
| 16 | 16 | import glob, json |
| 17 | 17 | import numpy as np, pandas as pd |
| 18 | −from src.common import ROOT, P, REGIONS, get_logger, ledger, register_output, write_json | |
| 18 | +from src.common import ROOT, P, REGIONS, get_logger, ledger, register_output, write_json, BASE_PROC, BASE_EXP | |
| 19 | 19 | |
| 20 | 20 | log = get_logger("clean.stores") |
| 21 | 21 | |
@@ -136,7 +136,7 @@ def main(): | ||
| 136 | 136 | "agreement_db_vs_pip": (round(float((out.region_db == out.region_pip)[out.region_db.notna() & out.region_pip.notna()].mean()), 4) if out.region_pip.notna().any() else None), |
| 137 | 137 | "agreement_city_vs_pip": (round(float((out.region_city == out.region_pip)[out.region_city.notna() & out.region_pip.notna()].mean()), 4) if out.region_city.notna().any() else None), |
| 138 | 138 | "agreement_db_vs_final": round(float((out.region_db == out.region_final)[out.region_db.notna()].mean()), 4)} |
| 139 | − write_json(summ, ROOT / "experiments" / "store_master.json", "clean.stores") | |
| 139 | + write_json(summ, BASE_EXP / "store_master.json", "clean.stores") | |
| 140 | 140 | ledger("clean.stores", **summ) |
| 141 | 141 | log.info(json.dumps(summ, ensure_ascii=False)) |
| 142 | 142 | |
modified
src/common.py
+7 −0
@@ -7,9 +7,16 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] | ||
| 7 | 7 | SETTINGS = yaml.safe_load((ROOT / "config" / "settings.yaml").read_text()) |
| 8 | 8 | SEED = int(SETTINGS["seed"]) |
| 9 | 9 | REGIONS = list(SETTINGS["regions"]) |
| 10 | +LABEL = os.environ.get("WP38_LABEL", "rule") # "rule" (platform keyword labels) or "model" (A-002 classifier labels) | |
| 11 | +SUFFIX = "" if LABEL == "rule" else f"_{LABEL}" | |
| 10 | 12 | P = {k: ROOT / v for k, v in SETTINGS["paths"].items()} |
| 13 | +P["processed"] = ROOT / f"data/processed{SUFFIX}"; P["experiments"] = ROOT / f"experiments{SUFFIX}" | |
| 14 | +P["figures"] = ROOT / f"paper/figures{SUFFIX}"; P["tables"] = ROOT / f"paper/tables{SUFFIX}" | |
| 11 | 15 | for _p in P.values(): |
| 12 | 16 | _p.mkdir(parents=True, exist_ok=True) |
| 17 | +PROC_DIR, EXP_DIR = P["processed"], P["experiments"] | |
| 18 | +BASE_PROC, BASE_EXP = ROOT / "data/processed", ROOT / "experiments" # label-independent artefacts (cleaning, audits, matching) | |
| 19 | +BASE_PROC.mkdir(parents=True, exist_ok=True); BASE_EXP.mkdir(parents=True, exist_ok=True) | |
| 13 | 20 | |
| 14 | 21 | |
| 15 | 22 | def get_logger(name: str) -> logging.Logger: |
added
src/features/category_model.py
+119 −0
@@ -0,0 +1,119 @@ | ||
| 1 | +"""Model-based category labels (Amendment A-002): the platform's keyword rules reach only ~46 % precision against annotator | |
| 2 | +consensus, so we train a supervised classifier on LLM-annotated listings and validate it on the independent 500-listing audit | |
| 3 | +sample (never used for training). | |
| 4 | + | |
| 5 | +Steps (python -m src.features.category_model <step>): | |
| 6 | + sample 6 000 listings stratified by rule category (proportional to size, floor 150; audit-sample uids excluded) -> data/processed/category_train_sample.csv | |
| 7 | + annotate claude-sonnet-5-5 (adaptive thinking, low effort) labels each listing with one of the 20 keys; usage logged; never overwritten | |
| 8 | + embed OpenAI text-embedding-3-small on "title | product_type | merchant_categories | vendor | tags" for ALL assortment listings | |
| 9 | + -> data/interim/listing_embeddings.npy (+ uid index); cached | |
| 10 | + train multinomial logistic regression on [embedding, one-hot rule label]; 5-fold CV; evaluation on the audit consensus sample | |
| 11 | + -> data/processed/category_model.parquet (uid, category_model, p_max), experiments/category_model.json | |
| 12 | +""" | |
| 13 | +from __future__ import annotations | |
| 14 | +import json, sys, time, concurrent.futures as cf | |
| 15 | +import numpy as np, pandas as pd | |
| 16 | +from dotenv import load_dotenv | |
| 17 | +from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json, BASE_PROC, BASE_EXP | |
| 18 | +from src.validation.category_audit import TAXO, SYSTEM, fiche, parse, SAMPLE as AUDIT | |
| 19 | + | |
| 20 | +log = get_logger("features.category_model"); load_dotenv(ROOT / ".env") | |
| 21 | +PROC = BASE_PROC; TRAIN = PROC / "category_train_sample.csv"; EMB = P["interim"] / "listing_embeddings.npy"; EMB_IDX = P["interim"] / "listing_embeddings_uids.csv" | |
| 22 | +N_TRAIN, FLOOR = 6000, 150 | |
| 23 | + | |
| 24 | + | |
| 25 | +def sample(): | |
| 26 | + if TRAIN.exists(): log.error("%s exists — refusing to overwrite", TRAIN); return | |
| 27 | + li = pd.read_parquet(P["interim"] / "listings_clean.parquet"); a = li[li.f_analysis_assort] | |
| 28 | + audit = set(pd.read_csv(AUDIT).uid); a = a[~a.uid.isin(audit)] | |
| 29 | + share = a.category.value_counts(normalize=True); n = (share * N_TRAIN).round().clip(lower=FLOOR).astype(int) | |
| 30 | + s = pd.concat([g.sample(min(int(n[c]), len(g)), random_state=SEED) for c, g in a.groupby("category")]) | |
| 31 | + cols = ["uid", "store_id", "category", "title", "product_type", "merchant_categories", "tags_list", "vendor", "price", "currency", "description_head"] | |
| 32 | + s[cols].to_csv(TRAIN, index=False); log.info("train sample %s", len(s)) | |
| 33 | + | |
| 34 | + | |
| 35 | +def _label(text): | |
| 36 | + import anthropic | |
| 37 | + m = anthropic.Anthropic().messages.create(model="claude-sonnet-5-5", max_tokens=600, thinking={"type": "adaptive"}, output_config={"effort": "low"}, system=SYSTEM, messages=[{"role": "user", "content": text}]) | |
| 38 | + return next((b.text for b in m.content if getattr(b, "type", "") == "text"), ""), {"in": m.usage.input_tokens, "out": m.usage.output_tokens} | |
| 39 | + | |
| 40 | + | |
| 41 | +def annotate(): | |
| 42 | + df = pd.read_csv(TRAIN) | |
| 43 | + if "llm" not in df.columns: df["llm"] = None | |
| 44 | + df.loc[df.llm.isin(["PARSE_ERROR", "INVALID"]), "llm"] = None | |
| 45 | + todo = df.index[df.llm.isna()].tolist(); usage = {"in": 0, "out": 0, "calls": 0, "errors": 0}; t0 = time.time() | |
| 46 | + def work(i): | |
| 47 | + r = df.loc[i] | |
| 48 | + for k in range(6): | |
| 49 | + try: return i, *_label(fiche(r)) | |
| 50 | + except Exception as e: time.sleep(min(60, 3 * 2 ** k)); err = str(e)[:100] | |
| 51 | + return i, f"ERROR {err}", {"in": 0, "out": 0} | |
| 52 | + with cf.ThreadPoolExecutor(8) as ex: | |
| 53 | + for j, (i, out, u) in enumerate(ex.map(work, todo)): | |
| 54 | + df.loc[i, "llm"] = parse(out)[0]; usage["in"] += u["in"]; usage["out"] += u["out"]; usage["calls"] += 1; usage["errors"] += out.startswith("ERROR") | |
| 55 | + if j % 500 == 499: df.to_csv(TRAIN, index=False); log.info("%s/%s %.0fs", j + 1, len(todo), time.time() - t0) | |
| 56 | + df.to_csv(TRAIN, index=False); ledger("category_model.annotate", model="claude-sonnet-5-5", **usage); log.info("annotate done %s", usage) | |
| 57 | + | |
| 58 | + | |
| 59 | +def text_of(r): | |
| 60 | + return " | ".join(str(x) for x in (r.title, r.product_type or "", r.merchant_categories or "", r.vendor or "", r.tags_list if r.tags_list != "[]" else "") if x)[:600] | |
| 61 | + | |
| 62 | + | |
| 63 | +def embed(): | |
| 64 | + from openai import OpenAI | |
| 65 | + li = pd.read_parquet(P["interim"] / "listings_clean.parquet"); a = li[li.f_analysis_assort | li.uid.isin(pd.read_csv(AUDIT).uid)] | |
| 66 | + texts = [text_of(r) for r in a.itertuples()]; uids = a.uid.tolist() | |
| 67 | + done = {} | |
| 68 | + if EMB.exists(): | |
| 69 | + old = np.load(EMB); oid = pd.read_csv(EMB_IDX).uid.tolist(); done = dict(zip(oid, old)) | |
| 70 | + todo = [i for i, u in enumerate(uids) if u not in done]; log.info("embedding %s of %s listings", len(todo), len(uids)) | |
| 71 | + client = OpenAI(); usage = 0; t0 = time.time() | |
| 72 | + def batch(idx): | |
| 73 | + for k in range(5): | |
| 74 | + try: | |
| 75 | + r = client.embeddings.create(model="text-embedding-3-small", input=[texts[i] for i in idx], dimensions=512); return idx, [e.embedding for e in r.data], r.usage.total_tokens | |
| 76 | + except Exception as e: time.sleep(min(60, 3 * 2 ** k)); err = str(e)[:100] | |
| 77 | + raise RuntimeError(err) | |
| 78 | + batches = [todo[i:i + 256] for i in range(0, len(todo), 256)] | |
| 79 | + with cf.ThreadPoolExecutor(8) as ex: | |
| 80 | + for j, (idx, vecs, tok) in enumerate(ex.map(batch, batches)): | |
| 81 | + for i, v in zip(idx, vecs): done[uids[i]] = np.asarray(v, np.float32) | |
| 82 | + usage += tok | |
| 83 | + if j % 200 == 199: log.info("%s/%s batches %.0fs", j + 1, len(batches), time.time() - t0) | |
| 84 | + M = np.stack([done[u] for u in uids]); np.save(EMB, M); pd.DataFrame({"uid": uids}).to_csv(EMB_IDX, index=False) | |
| 85 | + ledger("category_model.embed", model="text-embedding-3-small", listings=len(uids), tokens=usage); log.info("embeddings saved %s tokens", usage) | |
| 86 | + | |
| 87 | + | |
| 88 | +def train(): | |
| 89 | + from sklearn.linear_model import LogisticRegression | |
| 90 | + from sklearn.model_selection import cross_val_predict | |
| 91 | + M = np.load(EMB); uids = pd.read_csv(EMB_IDX).uid.tolist(); pos = {u: i for i, u in enumerate(uids)} | |
| 92 | + li = pd.read_parquet(P["interim"] / "listings_clean.parquet").set_index("uid") | |
| 93 | + cats = list(TAXO); onehot = lambda s: np.eye(len(cats))[[cats.index(c) if c in cats else cats.index("autre") for c in s]] | |
| 94 | + tr = pd.read_csv(TRAIN); tr = tr[tr.llm.isin(cats)] | |
| 95 | + Xtr = np.hstack([M[[pos[u] for u in tr.uid]], onehot(tr.category)]); ytr = tr.llm.values | |
| 96 | + clf = LogisticRegression(max_iter=2000, C=2.0, n_jobs=-1) | |
| 97 | + cvp = cross_val_predict(clf, Xtr, ytr, cv=5); cv_acc = float((cvp == ytr).mean()) | |
| 98 | + clf.fit(Xtr, ytr) | |
| 99 | + au = pd.read_csv(AUDIT); cons = au[au.llm_a == au.llm_b]; Xau = np.hstack([M[[pos[u] for u in cons.uid]], onehot(cons.category)]); pred_au = clf.predict(Xau) | |
| 100 | + acc_au = float((pred_au == cons.llm_a).mean()); rule_au = float((cons.category == cons.llm_a).mean()) | |
| 101 | + # listing-weighted accuracy (audit sample is stratified 25/category -> reweight by assortment shares of the RULE label) | |
| 102 | + a = li[li.f_analysis_assort]; w = a.category.value_counts(normalize=True); ww = cons.category.map(w).values; ww = ww / ww.sum() | |
| 103 | + acc_au_w = float(((pred_au == cons.llm_a) * ww).sum()); rule_au_w = float(((cons.category == cons.llm_a) * ww).sum()) | |
| 104 | + per = {c: {"n": int((cons.llm_a == c).sum()), "recall_model": round(float((pred_au[cons.llm_a == c] == c).mean()), 3) if (cons.llm_a == c).any() else None, "precision_model": round(float((cons.llm_a[pred_au == c] == c).mean()), 3) if (pred_au == c).any() else None} for c in cats} | |
| 105 | + conf = pd.crosstab(cons.llm_a.rename("annotated"), pd.Series(pred_au, index=cons.index, name="model")) | |
| 106 | + # predict all assortment listings | |
| 107 | + allu = a.index.tolist(); Xall = np.hstack([M[[pos[u] for u in allu]], onehot(a.category)]); prob = clf.predict_proba(Xall); pred = clf.classes_[prob.argmax(1)] | |
| 108 | + out = pd.DataFrame({"uid": allu, "category_rule": a.category.values, "category_model": pred, "p_max": prob.max(1)}) | |
| 109 | + out.to_parquet(PROC / "category_model.parquet", index=False); register_output(PROC / "category_model.parquet", "category_model") | |
| 110 | + res = {"train_n": int(len(tr)), "cv5_accuracy_vs_llm": round(cv_acc, 4), "audit_consensus_n": int(len(cons)), "accuracy_model_on_audit": round(acc_au, 4), "accuracy_rule_on_audit": round(rule_au, 4), | |
| 111 | + "listing_weighted_accuracy_model": round(acc_au_w, 4), "listing_weighted_accuracy_rule": round(rule_au_w, 4), "share_relabelled": round(float((out.category_rule != out.category_model).mean()), 4), | |
| 112 | + "category_shares_rule": a.category.value_counts(normalize=True).round(4).to_dict(), "category_shares_model": out.category_model.value_counts(normalize=True).round(4).to_dict(), | |
| 113 | + "per_category_on_audit": per, "confusion_audit_annotated_vs_model": {r: {c: int(v) for c, v in row.items() if v} for r, row in conf.to_dict("index").items()}, "features": "text-embedding-3-small (512 d) + one-hot rule label; multinomial logistic regression C=2"} | |
| 114 | + write_json(res, BASE_EXP / "category_model.json", "category_model"); ledger("category_model.train", **{k: v for k, v in res.items() if "per_" not in k and "confusion" not in k and "shares" not in k}) | |
| 115 | + log.info(json.dumps({k: v for k, v in res.items() if "per_" not in k and "confusion" not in k and "shares" not in k})) | |
| 116 | + | |
| 117 | + | |
| 118 | +if __name__ == "__main__": | |
| 119 | + {"sample": sample, "annotate": annotate, "embed": embed, "train": train}[sys.argv[1]]() | |
modified
src/features/diversity.py
+3 −3
@@ -3,10 +3,10 @@ HHI of category shares, rarefied entropy at m = 15 stores (2 000 draws), store-b | ||
| 3 | 3 | from __future__ import annotations |
| 4 | 4 | import json |
| 5 | 5 | import numpy as np, pandas as pd |
| 6 | −from src.common import ROOT, SEED, get_logger, ledger, register_output, write_json | |
| 6 | +from src.common import ROOT, SEED, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR, P | |
| 7 | 7 | |
| 8 | 8 | log = get_logger("features.diversity") |
| 9 | −PROC = ROOT / "data" / "processed" | |
| 9 | +PROC = PROC_DIR | |
| 10 | 10 | M_RAREFY, NB = 15, 2000 |
| 11 | 11 | |
| 12 | 12 | |
@@ -43,7 +43,7 @@ def main(): | ||
| 43 | 43 | allq = region_stats(d, "M"); store_cats = sc.groupby("store_id").category.nunique() |
| 44 | 44 | summ = {"quebec_H_norm_M": round(allq["H_norm"], 4), "quebec_eff_cat_M": round(allq["eff_cat"], 3), "regions_rarefied": int(out.H_rarefied15.notna().sum()), |
| 45 | 45 | "store_n_categories_dist": store_cats.value_counts().sort_index().to_dict(), "range_H_rarefied15": [round(float(out.H_rarefied15.min()), 3), round(float(out.H_rarefied15.max()), 3)]} |
| 46 | − write_json(summ, ROOT / "experiments" / "diversity.json", "diversity"); ledger("features.diversity", **summ); log.info(json.dumps(summ)) | |
| 46 | + write_json(summ, EXP_DIR / "diversity.json", "diversity"); ledger("features.diversity", **summ); log.info(json.dumps(summ)) | |
| 47 | 47 | |
| 48 | 48 | |
| 49 | 49 | if __name__ == "__main__": |
modified
src/features/match_products.py
+2 −2
@@ -18,7 +18,7 @@ from __future__ import annotations | ||
| 18 | 18 | import itertools, json, re, unicodedata |
| 19 | 19 | import numpy as np, pandas as pd, networkx as nx |
| 20 | 20 | from rapidfuzz import fuzz |
| 21 | −from src.common import ROOT, P, get_logger, ledger, register_output, write_json | |
| 21 | +from src.common import ROOT, P, get_logger, ledger, register_output, write_json, BASE_PROC, BASE_EXP | |
| 22 | 22 | from src.clean.listings import norm_title |
| 23 | 23 | |
| 24 | 24 | log = get_logger("features.match") |
@@ -123,7 +123,7 @@ def main(): | ||
| 123 | 123 | "groups_AC": int(gs.shape[0]), "groups_AC_multi_store": int((gs.n_stores >= 2).sum()), "listings_in_groups": len(gr), |
| 124 | 124 | "stores_in_groups": int(gr.store_id.nunique()), "groups_size_gt_10": int((gs.n > 10).sum()), |
| 125 | 125 | "regions_pairs": None} |
| 126 | − write_json(summ, ROOT / "experiments" / "matching.json", "match"); ledger("features.match_products", **summ) | |
| 126 | + write_json(summ, BASE_EXP / "matching.json", "match"); ledger("features.match_products", **summ) | |
| 127 | 127 | log.info(json.dumps(summ, ensure_ascii=False)) |
| 128 | 128 | |
| 129 | 129 | |
modified
src/features/region_category.py
+6 −3
@@ -6,16 +6,19 @@ plus store-level table data/processed/store_panel.parquet used by every model (o | ||
| 6 | 6 | from __future__ import annotations |
| 7 | 7 | import json |
| 8 | 8 | import numpy as np, pandas as pd |
| 9 | −from src.common import ROOT, P, REGIONS, get_logger, ledger, register_output, write_json | |
| 9 | +from src.common import ROOT, P, REGIONS, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR, LABEL | |
| 10 | 10 | |
| 11 | 11 | log = get_logger("features.rc") |
| 12 | −PROC = ROOT / "data" / "processed" | |
| 12 | +PROC = PROC_DIR | |
| 13 | 13 | |
| 14 | 14 | |
| 15 | 15 | def store_category_table(): |
| 16 | 16 | li = pd.read_parquet(P["interim"] / "listings_clean.parquet") |
| 17 | 17 | sm = pd.read_parquet(P["interim"] / "stores_master.parquet") |
| 18 | 18 | a = li[li.f_analysis_assort] |
| 19 | + if LABEL == "model": | |
| 20 | + cm = pd.read_parquet(BASE_PROC / "category_model.parquet").set_index("uid").category_model | |
| 21 | + a = a.assign(category=a.uid.map(cm).fillna(a.category)) | |
| 19 | 22 | sc = a.groupby(["store_id", "category"]).size().rename("n").reset_index() |
| 20 | 23 | sc = sc.merge(sm[["id", "region_final", "region_source", "n_listings", "platform", "store_kind", "certified", "first_source", "origin_class"]], left_on="store_id", right_on="id").drop(columns="id") |
| 21 | 24 | sc = sc.merge(sm[["id", "region_dbfirst", "region_db"]], left_on="store_id", right_on="id").drop(columns="id") |
@@ -53,7 +56,7 @@ def main(): | ||
| 53 | 56 | "listings_S_R": int(rc.query("sample=='S_R' and scheme=='L'").n.sum()), "listings_S_A": int(rc.query("sample=='S_A' and scheme=='L'").n.sum()), |
| 54 | 57 | "top1pct_cut": float(sc.groupby('store_id').n.sum().quantile(0.99)), |
| 55 | 58 | "stores_by_region_S_R": sp[sp.region_final != "Inconnue"].region_final.value_counts().to_dict()} |
| 56 | − write_json(summ, ROOT / "experiments" / "features_rc.json", "features.rc"); ledger("features.region_category", **summ); log.info(json.dumps(summ, ensure_ascii=False)) | |
| 59 | + write_json(summ, EXP_DIR / "features_rc.json", "features.rc"); ledger("features.region_category", **summ); log.info(json.dumps(summ, ensure_ascii=False)) | |
| 57 | 60 | |
| 58 | 61 | |
| 59 | 62 | if __name__ == "__main__": |
modified
src/models/h1_raa.py
+3 −3
@@ -5,10 +5,10 @@ Outputs experiments/h1_raa.json, data/processed/raa_matrix.csv (point estimates | ||
| 5 | 5 | from __future__ import annotations |
| 6 | 6 | import json |
| 7 | 7 | import numpy as np, pandas as pd |
| 8 | −from src.common import ROOT, P, REGIONS, SEED, get_logger, ledger, register_output, write_json | |
| 8 | +from src.common import ROOT, P, REGIONS, SEED, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR | |
| 9 | 9 | |
| 10 | 10 | log = get_logger("models.h1") |
| 11 | −PROC = ROOT / "data" / "processed" | |
| 11 | +PROC = PROC_DIR | |
| 12 | 12 | N_PERM, N_BOOT = 2000, 2000 |
| 13 | 13 | CATS = None |
| 14 | 14 | |
@@ -83,7 +83,7 @@ def main(): | ||
| 83 | 83 | res_m, mat_m = run(d, "S_R", "L", rng); res_m["scheme"] = "stores"; mat_m["scheme"] = "stores" |
| 84 | 84 | results.append(res_m); mats.append(mat_m) |
| 85 | 85 | pd.concat(mats).to_csv(PROC / "raa_matrix.csv", index=False); register_output(PROC / "raa_matrix.csv", "h1") |
| 86 | − write_json({"n_perm": N_PERM, "n_boot": N_BOOT, "results": results}, ROOT / "experiments" / "h1_raa.json", "h1"); ledger("models.h1_raa", results=results) | |
| 86 | + write_json({"n_perm": N_PERM, "n_boot": N_BOOT, "results": results}, EXP_DIR / "h1_raa.json", "h1"); ledger("models.h1_raa", results=results) | |
| 87 | 87 | |
| 88 | 88 | |
| 89 | 89 | if __name__ == "__main__": |
modified
src/models/h2_scale.py
+3 −3
@@ -6,10 +6,10 @@ from __future__ import annotations | ||
| 6 | 6 | import json |
| 7 | 7 | import numpy as np, pandas as pd |
| 8 | 8 | from scipy import stats |
| 9 | −from src.common import ROOT, SEED, get_logger, ledger, write_json | |
| 9 | +from src.common import ROOT, SEED, get_logger, ledger, write_json, PROC_DIR, EXP_DIR, P | |
| 10 | 10 | |
| 11 | 11 | log = get_logger("models.h2") |
| 12 | −PROC = ROOT / "data" / "processed" | |
| 12 | +PROC = PROC_DIR | |
| 13 | 13 | COV = {"log_population": ("population", "ISQ population estimate, 1 July 2025 (provisional)"), "log_business_locations": ("business_locations_with_employees", "ISQ business locations with employees, Dec 2025"), |
| 14 | 14 | "disposable_income": ("disposable_income_per_capita_cad", "ISQ disposable income per capita, 2024p"), "log_density": ("population_density_per_km2", "Census 2021 population density (2021 ER boundaries)")} |
| 15 | 15 | |
@@ -59,7 +59,7 @@ def main(): | ||
| 59 | 59 | xd = e.join(ind[["diversity"]], rsuffix="_i"); v3["partial_corr_eci_diversity_given_covariates"] = round(float(stats.spearmanr(y - X @ b, xd.diversity - X @ np.linalg.lstsq(X, xd.diversity.values, rcond=None)[0]).correlation), 3) |
| 60 | 60 | out["H7_V3"] = v3 |
| 61 | 61 | df.reset_index().rename(columns={"index": "region"}).to_csv(PROC / "h2_region_table.csv", index=False) |
| 62 | − write_json(out, ROOT / "experiments" / "h2_scale.json", "h2"); ledger("models.h2_scale", **{k: v for k, v in out.items() if k != "correlations"}, primary=out["correlations"].get("H_rarefied15 ~ log_population")) | |
| 62 | + write_json(out, EXP_DIR / "h2_scale.json", "h2"); ledger("models.h2_scale", **{k: v for k, v in out.items() if k != "correlations"}, primary=out["correlations"].get("H_rarefied15 ~ log_population")) | |
| 63 | 63 | log.info(json.dumps({k: v for k, v in out["correlations"].items() if "H_rarefied15" in k or "per_100k" in k})); log.info(json.dumps(v3)) |
| 64 | 64 | |
| 65 | 65 | |
modified
src/models/h3_longtail.py
+3 −3
@@ -6,10 +6,10 @@ from __future__ import annotations | ||
| 6 | 6 | import json |
| 7 | 7 | import numpy as np, pandas as pd |
| 8 | 8 | from scipy import stats, optimize, special |
| 9 | −from src.common import ROOT, SEED, get_logger, ledger, write_json | |
| 9 | +from src.common import ROOT, SEED, get_logger, ledger, write_json, PROC_DIR, EXP_DIR, P | |
| 10 | 10 | |
| 11 | 11 | log = get_logger("models.h3") |
| 12 | −PROC = ROOT / "data" / "processed" | |
| 12 | +PROC = PROC_DIR | |
| 13 | 13 | |
| 14 | 14 | |
| 15 | 15 | def gini(x): |
@@ -75,7 +75,7 @@ def main(): | ||
| 75 | 75 | "top10pct_share": round(top_share(v, 0.10), 3), "median": float(np.median(v))} |
| 76 | 76 | res["within_region"] = wr |
| 77 | 77 | pd.DataFrame({"rank": ranks, "n_listings": xs}).to_csv(PROC / "rank_size.csv", index=False) |
| 78 | − write_json(res, ROOT / "experiments" / "h3_longtail.json", "h3"); ledger("models.h3_longtail", **{k: v for k, v in res.items() if k != "within_region"}) | |
| 78 | + write_json(res, EXP_DIR / "h3_longtail.json", "h3"); ledger("models.h3_longtail", **{k: v for k, v in res.items() if k != "within_region"}) | |
| 79 | 79 | log.info(json.dumps({k: v for k, v in res.items() if k != "within_region"})) |
| 80 | 80 | |
| 81 | 81 | |
modified
src/models/h4_dynamics.py
+6 −3
@@ -8,10 +8,10 @@ status change or an 'added' burst in the outcome window. | ||
| 8 | 8 | from __future__ import annotations |
| 9 | 9 | import json |
| 10 | 10 | import numpy as np, pandas as pd |
| 11 | −from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json | |
| 11 | +from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR, BASE_PROC, BASE_EXP, LABEL | |
| 12 | 12 | |
| 13 | 13 | log = get_logger("models.h4") |
| 14 | −PROC = ROOT / "data" / "processed" | |
| 14 | +PROC = PROC_DIR | |
| 15 | 15 | BASE = (pd.Timestamp("2026-08-31", tz="UTC"), pd.Timestamp("2026-09-28", tz="UTC")) |
| 16 | 16 | OUT = (pd.Timestamp("2026-09-28", tz="UTC"), pd.Timestamp("2026-11-02", tz="UTC")) |
| 17 | 17 | |
@@ -46,6 +46,9 @@ def main(): | ||
| 46 | 46 | rng = np.random.default_rng(SEED) |
| 47 | 47 | li = pd.read_parquet(P["interim"] / "listings_clean.parquet"); li = li[li.f_published | (li.active == 0)] # all live-store listings incl. inactive |
| 48 | 48 | li = li[li.f_live_store & ~li.f_hors_mission & ~li.f_sibling_dup] |
| 49 | + if LABEL == "model": | |
| 50 | + cm = pd.read_parquet(BASE_PROC / "category_model.parquet").set_index("uid").category_model | |
| 51 | + li = li.assign(category=li.uid.map(cm).fillna(li.category)) | |
| 49 | 52 | sp = pd.read_parquet(PROC / "store_panel.parquet").set_index("id") |
| 50 | 53 | base = present(li, *BASE); out = present(li, *OUT) |
| 51 | 54 | phi, pv = proximity(base) |
@@ -87,7 +90,7 @@ def main(): | ||
| 87 | 90 | "region_level_entries": region_entries, "region_level_exits": region_exits, "n_region_entries": len(region_entries), |
| 88 | 91 | "share_entering_stores_with_added_burst": share_burst} |
| 89 | 92 | D.to_parquet(PROC / "h4_panel.parquet", index=False); register_output(PROC / "h4_panel.parquet", "h4") |
| 90 | − write_json(res, ROOT / "experiments" / "h4_dynamics.json", "h4"); ledger("models.h4_dynamics", **{k: v for k, v in res.items() if "region_level" not in k}) | |
| 93 | + write_json(res, EXP_DIR / "h4_dynamics.json", "h4"); ledger("models.h4_dynamics", **{k: v for k, v in res.items() if "region_level" not in k}) | |
| 91 | 94 | log.info(json.dumps({k: v for k, v in res.items() if "region_level" not in k})) |
| 92 | 95 | |
| 93 | 96 | |
modified
src/models/h5_hedonic.py
+3 −3
@@ -10,10 +10,10 @@ Outputs experiments/h5_hedonic.json, data/processed/h5_groups.parquet, data/proc | ||
| 10 | 10 | from __future__ import annotations |
| 11 | 11 | import json |
| 12 | 12 | import numpy as np, pandas as pd |
| 13 | −from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json | |
| 13 | +from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR | |
| 14 | 14 | |
| 15 | 15 | log = get_logger("models.h5") |
| 16 | −PROC = ROOT / "data" / "processed" | |
| 16 | +PROC = PROC_DIR | |
| 17 | 17 | B = 9999 |
| 18 | 18 | |
| 19 | 19 | |
@@ -120,7 +120,7 @@ def main(): | ||
| 120 | 120 | g = s.assign(r=r).groupby("store_id").apply(lambda h: (h.x * h.r).sum()); se = float(np.sqrt((g ** 2).sum()) / (s.x ** 2).sum()) |
| 121 | 121 | cert = {"note": "descriptive store-certification contrast (Aliments du Québec / Les Produits du Québec), category FE, store-clustered SE — NOT a test of H6", |
| 122 | 122 | "obs": int(len(s)), "stores": int(s.store_id.nunique()), "certified_stores": int(s.groupby('store_id').cert.first().sum()), "b_log_price": round(b, 4), "se": round(se, 4)} |
| 123 | − write_json({"models": out, "certification_contrast": cert}, ROOT / "experiments" / "h5_hedonic.json", "h5"); ledger("models.h5_hedonic", models=[{k: v for k, v in m.items() if k not in ("coef", "stores_by_region")} for m in out], cert=cert) | |
| 123 | + write_json({"models": out, "certification_contrast": cert}, EXP_DIR / "h5_hedonic.json", "h5"); ledger("models.h5_hedonic", models=[{k: v for k, v in m.items() if k not in ("coef", "stores_by_region")} for m in out], cert=cert) | |
| 124 | 124 | log.info(json.dumps(cert)) |
| 125 | 125 | |
| 126 | 126 | |
modified
src/models/h7_complexity.py
+3 −3
@@ -8,11 +8,11 @@ from __future__ import annotations | ||
| 8 | 8 | import json |
| 9 | 9 | import numpy as np, pandas as pd, networkx as nx |
| 10 | 10 | from scipy import stats |
| 11 | −from src.common import ROOT, SEED, get_logger, ledger, register_output, write_json | |
| 11 | +from src.common import ROOT, SEED, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR, P | |
| 12 | 12 | from src.models.h1_raa import weights, table, raa |
| 13 | 13 | |
| 14 | 14 | log = get_logger("models.h7") |
| 15 | −PROC = ROOT / "data" / "processed" | |
| 15 | +PROC = PROC_DIR | |
| 16 | 16 | |
| 17 | 17 | |
| 18 | 18 | def eci(M): |
@@ -111,7 +111,7 @@ def main(): | ||
| 111 | 111 | "obs_corr_eci_diversity": round(obs_corr_div, 3), "null_corr_eci_diversity_p5_p95": [round(float(np.nanpercentile(null_corr_div, 5)), 3), round(float(np.nanpercentile(null_corr_div, 95)), 3)], |
| 112 | 112 | "pass": bool(p_l2 < 0.05)}, |
| 113 | 113 | "V4_community_stability": v4, "mst_edges": [(a, b, round(w, 3)) for a, b, w in mst.edges(data="weight")]} |
| 114 | − write_json(res, ROOT / "experiments" / "h7_complexity.json", "h7"); ledger("models.h7_complexity", **{k: v for k, v in res.items() if k not in ("mst_edges",)}) | |
| 114 | + write_json(res, EXP_DIR / "h7_complexity.json", "h7"); ledger("models.h7_complexity", **{k: v for k, v in res.items() if k not in ("mst_edges",)}) | |
| 115 | 115 | log.info(json.dumps({k: v for k, v in res.items() if k in ("M_density", "V1_bootstrap_rank_stability", "V2_reshuffle_null", "V4_community_stability")}, ensure_ascii=False)[:900]) |
| 116 | 116 | |
| 117 | 117 | |
modified
src/models/qdaci.py
+4 −4
@@ -7,12 +7,12 @@ from __future__ import annotations | ||
| 7 | 7 | import json |
| 8 | 8 | import numpy as np, pandas as pd |
| 9 | 9 | from scipy import stats |
| 10 | −from src.common import ROOT, SEED, get_logger, ledger, register_output, write_json | |
| 10 | +from src.common import ROOT, SEED, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR, P | |
| 11 | 11 | from src.models.h1_raa import weights, table, raa |
| 12 | 12 | from src.models.h3_longtail import gini |
| 13 | 13 | |
| 14 | 14 | log = get_logger("models.qdaci") |
| 15 | −PROC = ROOT / "data" / "processed" | |
| 15 | +PROC = PROC_DIR | |
| 16 | 16 | |
| 17 | 17 | |
| 18 | 18 | def spec_index(d, regions, cats): |
@@ -25,7 +25,7 @@ def main(): | ||
| 25 | 25 | rng = np.random.default_rng(SEED) |
| 26 | 26 | sc = pd.read_parquet(PROC / "store_category.parquet"); d = sc[sc.region_final != "Inconnue"].reset_index(drop=True) |
| 27 | 27 | regions = sorted(d.region_final.unique()); cats = sorted(d.category.unique()) |
| 28 | − div = pd.read_csv(PROC / "diversity_region.csv").set_index("region"); h7 = json.load(open(ROOT / "experiments" / "h7_complexity.json")) | |
| 28 | + div = pd.read_csv(PROC / "diversity_region.csv").set_index("region"); h7 = json.load(open(EXP_DIR / "h7_complexity.json")) | |
| 29 | 29 | ext = pd.read_csv(ROOT / "data" / "external" / "regions_external.csv").set_index("region_name"); sp = pd.read_parquet(PROC / "store_panel.parquet") |
| 30 | 30 | spec = pd.Series(spec_index(d, regions, cats), index=regions) |
| 31 | 31 | # bootstrap specialization |
@@ -62,7 +62,7 @@ def main(): | ||
| 62 | 62 | "publish_composite": bool(np.median(rhos) >= 0.7), "note": "Components describe observed online catalogues of Quebec-based merchants, not production or output. Complexity omitted because the ECI failed the preregistered stability criterion." if not include_eci else "Components describe observed online catalogues, not production."} |
| 63 | 63 | comp["composite_equal"] = eq.reindex(regions); comp["composite_pca"] = pc.reindex(regions); comp["composite_borda"] = borda.reindex(regions) |
| 64 | 64 | comp.reset_index().rename(columns={"index": "region"}).to_csv(PROC / "qdaci_components.csv", index=False); register_output(PROC / "qdaci_components.csv", "qdaci") |
| 65 | − write_json(res, ROOT / "experiments" / "qdaci.json", "qdaci"); ledger("models.qdaci", **res); log.info(json.dumps(res)) | |
| 65 | + write_json(res, EXP_DIR / "qdaci.json", "qdaci"); ledger("models.qdaci", **res); log.info(json.dumps(res)) | |
| 66 | 66 | |
| 67 | 67 | |
| 68 | 68 | if __name__ == "__main__": |
added
src/models/summary.py
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +"""Hypothesis registry: confirmatory family {H1, H2, H3, H5-T1, H5-T2} with Holm step-down; exploratory/validation outputs listed with status. | |
| 2 | +Writes experiments/hypothesis_registry.json and reports/hypothesis_registry.md.""" | |
| 3 | +from __future__ import annotations | |
| 4 | +import json | |
| 5 | +import numpy as np | |
| 6 | +from src.common import ROOT, get_logger, write_json, PROC_DIR, EXP_DIR, P | |
| 7 | + | |
| 8 | +log = get_logger("models.summary"); EXP = EXP_DIR | |
| 9 | +J = lambda n: json.load(open(EXP / n)) | |
| 10 | + | |
| 11 | + | |
| 12 | +def holm(p): | |
| 13 | + keys = sorted(p, key=lambda k: p[k]); m = len(keys); adj = {}; running = 0 | |
| 14 | + for i, k in enumerate(keys): | |
| 15 | + running = max(running, (m - i) * p[k]); adj[k] = min(1.0, running) | |
| 16 | + return adj | |
| 17 | + | |
| 18 | + | |
| 19 | +def main(label="rule"): | |
| 20 | + h1 = {(r["sample"], r["scheme"]): r for r in J("h1_raa.json")["results"]}; h2 = J("h2_scale.json"); h3 = J("h3_longtail.json"); h5 = J("h5_hedonic.json")["models"][0]; h7 = J("h7_complexity.json"); h4 = J("h4_dynamics.json"); q = J("qdaci.json") | |
| 21 | + pM, pT, pL = h1[("S_R", "M")]["p_perm"], h1[("S_R", "T")]["p_perm"], h1[("S_R", "L")]["p_perm"] | |
| 22 | + c2 = h2["correlations"]["H_rarefied15 ~ log_population"] | |
| 23 | + raw = {"H1": max(pM, pT), "H2": None, "H3": None, "H5-T1": h5["p_region_wildboot"], "H5-T2": h5["p_reseller_wildboot"]} | |
| 24 | + # H2 and H3 have CI/threshold decisions; for Holm we convert H2 to a two-sided permutation-free p via Fisher z (reported as approximate) | |
| 25 | + n2 = c2["n"]; z = np.arctanh(c2["rho"]) * np.sqrt(max(n2 - 3, 1)); from scipy.stats import norm; raw["H2"] = float(2 * (1 - norm.cdf(abs(z)))) | |
| 26 | + raw["H3"] = 0.0 if h3["decision_prereg"] else 1.0 # threshold criterion with bootstrap CIs; p-value placeholder for the Holm ordering only | |
| 27 | + adj = holm({k: v for k, v in raw.items()}) | |
| 28 | + reg = [ | |
| 29 | + {"id": "H1", "statement": "Online assortment composition differs across regions beyond what merchant counts imply", "primary_statistic": f"Cramér's V (M) = {h1[('S_R','M')]['cramers_v']:.3f}, store-reshuffle p = {pM:.4f}; (T) V = {h1[('S_R','T')]['cramers_v']:.3f}, p = {pT:.3f}; (L) p = {pL:.3f}", "p_raw": raw["H1"], "p_holm": adj["H1"], | |
| 30 | + "status": "partially supported" if pM < 0.05 and pT >= 0.05 else ("supported" if adj["H1"] < 0.05 else "not supported"), "note": "Preregistered criterion required p < 0.05 under both merchant-equal and trimmed-listing weights; met for merchant-equal only. Specialization is a property of the merchant population, not of listing volumes."}, | |
| 31 | + {"id": "H2", "statement": "Regional diversity is associated with regional scale", "primary_statistic": f"Spearman rho(rarefied entropy, log population) = {c2['rho']:.2f}, 95% CI [{c2['ci'][0]:.2f}; {c2['ci'][1]:.2f}], n = {c2['n']}", "p_raw": raw["H2"], "p_holm": adj["H2"], | |
| 32 | + "status": "inconclusive" if c2["ci"][0] < 0 < c2["ci"][1] else "supported", "note": "CI includes zero and economically meaningful values in both directions; n = 16 regions. No association with business counts or density either (see table)."}, | |
| 33 | + {"id": "H3", "statement": "Merchant catalogue sizes are highly skewed", "primary_statistic": f"Gini = {h3['gini']:.3f} [{h3['gini_ci'][0]:.3f}; {h3['gini_ci'][1]:.3f}]; top-10% share = {h3['top10pct_share']:.3f} [{h3['top10_ci'][0]:.3f}; {h3['top10_ci'][1]:.3f}]", "p_raw": None, "p_holm": None, | |
| 34 | + "status": "supported" if h3["decision_prereg"] else "not supported", "note": "Both preregistered thresholds (Gini >= 0.70, top-10% >= 0.50) exceeded with CIs entirely above them; power-law tail favoured over log-normal on the tail."}, | |
| 35 | + {"id": "H4", "statement": "Related diversification (exploratory)", "primary_statistic": f"LPM density coefficient {h4['lpm_density_coef']:.3f} (SE {h4['se_cluster_store']:.3f}), placebo p = {h4['placebo_p']:.3f}; {h4['entries']} entries; {len(h4['region_level_entries'])} region-level entries", "p_raw": None, "p_holm": None, | |
| 36 | + "status": "exploratory: consistent with relatedness at the store level; region level untestable", "note": "Fewer than 10 region-level entries (preregistered untestability rule); store-level association over 5 weeks, no causal claim."}, | |
| 37 | + {"id": "H5-T1", "statement": "Matched-product prices are geographically structured", "primary_statistic": f"Wald({h5['df_region']}) = {h5['wald_region']:.1f}, wild-cluster-bootstrap p = {h5['p_region_wildboot']:.4f}; {h5['groups']} groups, {h5['stores']} stores, {h5['regions_with_ge5_stores']} regions with >= 5 stores", "p_raw": raw["H5-T1"], "p_holm": adj["H5-T1"], | |
| 38 | + "status": "supported" if adj["H5-T1"] < 0.05 else "not supported", "note": "Region effects identified only through groups spanning regions; posted prices, no shipping/taxes."}, | |
| 39 | + {"id": "H5-T2", "statement": "Matched-product prices are competitively structured (reseller vs producer)", "primary_statistic": f"reseller coefficient {h5['coef']['reseller']['b']:.3f} (SE {h5['coef']['reseller']['se_cluster']:.3f}), wild-bootstrap p = {h5['p_reseller_wildboot']:.4f}", "p_raw": raw["H5-T2"], "p_holm": adj["H5-T2"], | |
| 40 | + "status": "supported" if adj["H5-T2"] < 0.05 else "not supported", "note": "Resellers/collectives post lower prices than producers for the same product."}, | |
| 41 | + {"id": "H6", "statement": "Local-origin price premium", "primary_statistic": "not estimated", "p_raw": None, "p_holm": None, "status": "untestable (declared ex ante)", "note": "No product-level provenance; a store-certification contrast is reported descriptively and is not H6."}, | |
| 42 | + {"id": "H7", "statement": "Validated digital-assortment complexity indicator", "primary_statistic": f"ECI bootstrap rank stability median rho = {h7['V1_bootstrap_rank_stability']['eci_rho_median']:.2f} (criterion 0.8); lambda2 null p = {h7['V2_reshuffle_null']['p_lambda2']:.2f}; LOO predictive rho = {h2['H7_V3']['loo_pred_spearman']:.2f}", "p_raw": None, "p_holm": None, | |
| 43 | + "status": "not validated: ECI unstable at 17 x 20; omitted from QDACI", "note": "Fitness and plain diversity are more stable but still below the criterion; the region-category matrix is not more nested than the store-reshuffle null."}, | |
| 44 | + {"id": "QDACI", "statement": "Composite index", "primary_statistic": f"median agreement across weightings rho = {q['composite_agreement_spearman']['median']:.2f} (rule 0.7); bootstrap rank stability {q['bootstrap_rank_stability_equal_weights_median_rho']:.2f}", "p_raw": None, "p_holm": None, | |
| 45 | + "status": "components published; composite withheld" if not q["publish_composite"] else "composite published", "note": q["note"]}, | |
| 46 | + ] | |
| 47 | + write_json({"labelling": label, "holm_family": list(raw), "registry": reg}, EXP / f"hypothesis_registry_{label}.json", "summary") | |
| 48 | + md = [f"# Hypothesis registry ({label} category labelling)", "", "| id | statement | primary statistic | p raw | p Holm | status |", "|---|---|---|---|---|---|"] | |
| 49 | + md += [f"| {r['id']} | {r['statement']} | {r['primary_statistic']} | {'' if r['p_raw'] is None else f'{r['p_raw']:.4f}'} | {'' if r['p_holm'] is None else f'{r['p_holm']:.4f}'} | **{r['status']}** |" for r in reg] | |
| 50 | + md += ["", "Notes:"] + [f"- {r['id']}: {r['note']}" for r in reg] | |
| 51 | + (ROOT / "reports" / f"hypothesis_registry_{label}.md").write_text("\n".join(md)); log.info("\n".join(f"{r['id']}: {r['status']}" for r in reg)) | |
| 52 | + | |
| 53 | + | |
| 54 | +if __name__ == "__main__": | |
| 55 | + import sys; main(sys.argv[1] if len(sys.argv) > 1 else "rule") | |
modified
src/reports/make_figures.py
+8 −8
@@ -6,10 +6,10 @@ import numpy as np, pandas as pd, matplotlib | ||
| 6 | 6 | matplotlib.use("Agg") |
| 7 | 7 | import matplotlib.pyplot as plt |
| 8 | 8 | from matplotlib.colors import TwoSlopeNorm, LinearSegmentedColormap |
| 9 | −from src.common import ROOT, P, REGIONS, get_logger, ledger | |
| 9 | +from src.common import ROOT, P, REGIONS, get_logger, ledger, PROC_DIR, EXP_DIR, BASE_PROC, BASE_EXP | |
| 10 | 10 | |
| 11 | 11 | log = get_logger("reports.figures") |
| 12 | −PROC = ROOT / "data" / "processed"; FIG = ROOT / "paper" / "figures"; FIG.mkdir(exist_ok=True) | |
| 12 | +PROC = PROC_DIR; FIG = P["figures"]; FIG.mkdir(exist_ok=True) | |
| 13 | 13 | C = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4"]; GRAY = "#8a8a85" |
| 14 | 14 | SEQ = LinearSegmentedColormap.from_list("seqblue", ["#cde2fb", "#2a78d6", "#0d366b"]) |
| 15 | 15 | DIV = LinearSegmentedColormap.from_list("divob", ["#eb6834", "#f2f2ef", "#2a78d6"]) |
@@ -44,7 +44,7 @@ def fig_raa_heatmap(): | ||
| 44 | 44 | |
| 45 | 45 | |
| 46 | 46 | def fig_rank_size(): |
| 47 | − rs = pd.read_csv(PROC / "rank_size.csv"); h3 = json.load(open(ROOT / "experiments" / "h3_longtail.json")) | |
| 47 | + rs = pd.read_csv(PROC / "rank_size.csv"); h3 = json.load(open(EXP_DIR / "h3_longtail.json")) | |
| 48 | 48 | fig, ax = plt.subplots(figsize=(5.2, 3.8)); ax.loglog(rs["rank"], rs.n_listings, color=C[0], lw=1.6) |
| 49 | 49 | pl = h3["powerlaw"]; xm = pl["xmin"]; ax.axhline(xm, color=GRAY, lw=0.8, ls="--"); ax.text(1.2, xm * 1.15, f"power-law tail from {xm} listings (α = {pl['alpha']:.2f})", color="#444", fontsize=8) |
| 50 | 50 | ax.set_xlabel("Store rank (by catalogue size)"); ax.set_ylabel("Listings in the assortment sample") |
@@ -82,7 +82,7 @@ def fig_region_effects(): | ||
| 82 | 82 | |
| 83 | 83 | def fig_network(): |
| 84 | 84 | import networkx as nx |
| 85 | − h7 = json.load(open(ROOT / "experiments" / "h7_complexity.json")); phi = pd.read_csv(PROC / "category_proximity.csv", index_col=0) | |
| 85 | + h7 = json.load(open(EXP_DIR / "h7_complexity.json")); phi = pd.read_csv(PROC / "category_proximity.csv", index_col=0) | |
| 86 | 86 | G = nx.Graph() |
| 87 | 87 | for a in phi.index: |
| 88 | 88 | for b in phi.columns: |
@@ -98,7 +98,7 @@ def fig_network(): | ||
| 98 | 98 | |
| 99 | 99 | |
| 100 | 100 | def fig_attrition(): |
| 101 | − flow = json.load(open(ROOT / "experiments" / "sample_flow.json"))["flow"] | |
| 101 | + flow = json.load(open(BASE_EXP / "sample_flow.json"))["flow"] | |
| 102 | 102 | fig, ax = plt.subplots(figsize=(7.4, 3.2)); labels = [k.replace("… ", "").replace(" [ASSORTMENT SAMPLE]", "").replace(" [PRICE SAMPLE]", "") for k, _ in flow]; vals = [v for _, v in flow] |
| 103 | 103 | y = np.arange(len(vals))[::-1]; ax.barh(y, vals, color=[C[0] if "SAMPLE" in k else "#9ec5f4" for k, _ in flow], height=0.6) |
| 104 | 104 | for yi, v, (k, _) in zip(y, vals, flow): ax.text(v + 3000, yi, f"{v:,}".replace(",", " "), va="center", fontsize=8) |
@@ -107,7 +107,7 @@ def fig_attrition(): | ||
| 107 | 107 | |
| 108 | 108 | |
| 109 | 109 | def fig_h7(): |
| 110 | − h7 = json.load(open(ROOT / "experiments" / "h7_complexity.json")); ind = pd.read_csv(PROC / "h7_region_indicators.csv") | |
| 110 | + h7 = json.load(open(EXP_DIR / "h7_complexity.json")); ind = pd.read_csv(PROC / "h7_region_indicators.csv") | |
| 111 | 111 | fig, axes = plt.subplots(1, 2, figsize=(8, 3.4)) |
| 112 | 112 | axes[0].scatter(ind.diversity, ind.eci, color=C[0], s=28) |
| 113 | 113 | for _, r in ind.iterrows(): axes[0].annotate(SHORT.get(r.region, r.region), (r.diversity, r.eci), fontsize=6.5, xytext=(3, 2), textcoords="offset points") |
@@ -118,7 +118,7 @@ def fig_h7(): | ||
| 118 | 118 | |
| 119 | 119 | |
| 120 | 120 | def fig_h4(): |
| 121 | − h4 = json.load(open(ROOT / "experiments" / "h4_dynamics.json")); d = pd.read_parquet(PROC / "h4_panel.parquet") | |
| 121 | + h4 = json.load(open(EXP_DIR / "h4_dynamics.json")); d = pd.read_parquet(PROC / "h4_panel.parquet") | |
| 122 | 122 | d["bin"] = pd.qcut(d.density, 10, labels=False, duplicates="drop"); g = d.groupby("bin").agg(x=("density", "mean"), y=("entry", "mean"), n=("entry", "size")) |
| 123 | 123 | fig, ax = plt.subplots(figsize=(5, 3.4)); ax.scatter(g.x, g.y, color=C[0], s=30); ax.set_xlabel("Relatedness density of the absent category (baseline weeks)"); ax.set_ylabel("Share of (store, category) cells with entry") |
| 124 | 124 | ax.set_title(f"LPM slope {h4['lpm_density_coef']:.3f} (cluster SE {h4['se_cluster_store']:.3f}); placebo 95th pct {h4['placebo_p95']:.3f}", fontsize=8) |
@@ -126,7 +126,7 @@ def fig_h4(): | ||
| 126 | 126 | |
| 127 | 127 | |
| 128 | 128 | def fig_robustness(): |
| 129 | − rb = json.load(open(ROOT / "experiments" / "robustness.json")); keys = [k for k in rb if isinstance(rb[k], dict) and "h1_M" in rb[k]] | |
| 129 | + rb = json.load(open(EXP_DIR / "robustness.json")); keys = [k for k in rb if isinstance(rb[k], dict) and "h1_M" in rb[k]] | |
| 130 | 130 | fig, ax = plt.subplots(figsize=(6.2, 3.8)); y = np.arange(len(keys))[::-1] |
| 131 | 131 | for j, (sch, col, lab) in enumerate((("h1_M", C[0], "merchant-equal (M)"), ("h1_T", C[1], "trimmed listings (T)"))): |
| 132 | 132 | v = [rb[k][sch]["V"] for k in keys]; nm = [rb[k][sch]["null_mean"] for k in keys] |
modified
src/reports/make_tables.py
+9 −9
@@ -2,10 +2,10 @@ | ||
| 2 | 2 | from __future__ import annotations |
| 3 | 3 | import json |
| 4 | 4 | import numpy as np, pandas as pd |
| 5 | −from src.common import ROOT, get_logger, ledger | |
| 5 | +from src.common import ROOT, get_logger, ledger, PROC_DIR, EXP_DIR, P, BASE_PROC, BASE_EXP | |
| 6 | 6 | |
| 7 | 7 | log = get_logger("reports.tables") |
| 8 | −PROC = ROOT / "data" / "processed"; TAB = ROOT / "paper" / "tables"; TAB.mkdir(exist_ok=True); EXP = ROOT / "experiments" | |
| 8 | +PROC = PROC_DIR; TAB = P["tables"]; TAB.mkdir(exist_ok=True); EXP = EXP_DIR | |
| 9 | 9 | SHORT = {"Saguenay–Lac-Saint-Jean": "Saguenay--Lac-Saint-Jean", "Gaspésie–Îles-de-la-Madeleine": "Gaspésie--Îles-de-la-Madeleine"} |
| 10 | 10 | CAT = {"erable": "Maple \\& honey", "epicerie": "Fine grocery", "cafe_the": "Coffee \\& tea", "chocolat": "Chocolate", "boulangerie": "Bakery", "viande_poisson": "Meat \\& fish", "fromage_laitier": "Cheese \\& dairy", "boissons": "Beverages", "alcool": "Alcohol", "sante_beaute": "Beauty \\& care", "maison": "Home \\& decor", "mode": "Fashion", "bijoux": "Jewellery", "art": "Art \\& craft", "enfants": "Children", "animaux": "Pets", "plein_air": "Outdoor \\& sport", "jardin": "Garden", "industriel": "Industrial", "autre": "Other"} |
| 11 | 11 | |
@@ -24,7 +24,7 @@ def write(name, header, rows, caption, label, notes="", align=None, size="\\smal | ||
| 24 | 24 | |
| 25 | 25 | |
| 26 | 26 | def t_sample_flow(): |
| 27 | − f = J("sample_flow.json")["flow"] | |
| 27 | + f = json.load(open(BASE_EXP / "sample_flow.json"))["flow"] | |
| 28 | 28 | write("sample_flow", ["Step", "Listings"], [[esc(k.replace("…", "\\ldots").replace("[", "\\textbf{[").replace("]", "]}")), n(v)] for k, v in f], |
| 29 | 29 | "Sample flow from database rows to the analysis samples", "sample_flow", "Snapshot of 2026-11-01T17:38Z. Nothing is deleted: each step is a flag in the cleaned listing table. The assortment sample is used for H1--H4 and H7; the price sample for H5.") |
| 30 | 30 | |
@@ -113,19 +113,19 @@ def t_h4(): | ||
| 113 | 113 | |
| 114 | 114 | |
| 115 | 115 | def t_audits(): |
| 116 | − p = EXP / "category_audit.json" | |
| 116 | + p = BASE_EXP / "category_audit.json" | |
| 117 | 117 | if p.exists(): |
| 118 | − a = J("category_audit.json"); pc = a["per_category_precision_vs_consensus"] | |
| 118 | + a = json.load(open(p)); pc = a["per_category_precision_vs_consensus"] | |
| 119 | 119 | rows = [[CAT.get(k, k), v["n_consensus"], f"{v['precision']:.2f}" if v["precision"] is not None else "--"] for k, v in pc.items()] |
| 120 | 120 | rows.append(["\\textbf{All}", int(a["n"] * a["consensus_share"]), f"\\textbf{{{a['rule_precision_vs_consensus']:.2f}}}"]) |
| 121 | 121 | write("category_audit", ["Rule label", "Listings with annotator consensus", "Precision of the rule label"], rows, "Audit of the rule-based category labels (500-listing stratified sample)", "cat_audit", |
| 122 | − f"25 random listings per category annotated independently by two LLMs (claude-opus-5-5, gpt-5.5) with the platform's taxonomy; agreement rule vs A {a['agree_category_llm_a']:.2f}, rule vs B {a['agree_category_llm_b']:.2f}, A vs B {a['agree_llm_a_llm_b']:.2f}; consensus exists for {a['consensus_share']:.0%} of listings. Precision = share of consensus labels equal to the rule label. Disagreements listed in the replication package.", size="\\footnotesize") | |
| 123 | − p = EXP / "match_validation.json" | |
| 122 | + f"25 random listings per category annotated independently by two LLMs (claude-opus-5-5, gpt-5.5) with the platform's taxonomy; agreement rule vs A {a['agree_category_llm_a']:.2f}, rule vs B {a['agree_category_llm_b']:.2f}, A vs B {a['agree_llm_a_llm_b']:.2f}; consensus exists for {100*a['consensus_share']:.0f}\\% of listings. Precision = share of consensus labels equal to the rule label. Disagreements listed in the replication package.", size="\\footnotesize") | |
| 123 | + p = BASE_EXP / "match_validation.json" | |
| 124 | 124 | if p.exists(): |
| 125 | − m = J("match_validation.json"); pt = m["precision_by_tier_consensus"] | |
| 125 | + m = json.load(open(p)); pt = m["precision_by_tier_consensus"] | |
| 126 | 126 | rows = [[t, v["n"], f"{v['yes']:.2f}", f"{v['no']:.2f}", f"{m['precision_by_tier_comparable_only'].get(t, float('nan')):.2f}"] for t, v in pt.items()] |
| 127 | 127 | write("match_validation", ["Tier", "Pairs with consensus", "Same product", "Different", "Same product, comparable-variant pairs"], rows, "Validation of the cross-merchant matching tiers (60 pairs per tier)", "match_val", |
| 128 | − f"Two independent LLM annotators (agreement {m['agree_llm']:.2f}; consensus on {m['consensus_share']:.0%} of pairs). Tier A = shared merchant SKU; B = same brand and identical normalised title; C = same brand, fuzzy title; D = identical title without brand (never used for inference).", size="\\footnotesize") | |
| 128 | + f"Two independent LLM annotators (agreement {m['agree_llm']:.2f}; consensus on {100*m['consensus_share']:.0f}\\% of pairs). Tier A = shared merchant SKU; B = same brand and identical normalised title; C = same brand, fuzzy title; D = identical title without brand (never used for inference).", size="\\footnotesize") | |
| 129 | 129 | |
| 130 | 130 | |
| 131 | 131 | def t_h2(): |
modified
src/reports/phase2_audit.py
+2 −2
@@ -2,10 +2,10 @@ | ||
| 2 | 2 | from __future__ import annotations |
| 3 | 3 | import json |
| 4 | 4 | import pandas as pd |
| 5 | −from src.common import ROOT, get_logger | |
| 5 | +from src.common import ROOT, get_logger, BASE_PROC, BASE_EXP, P | |
| 6 | 6 | |
| 7 | 7 | log = get_logger("reports.phase2") |
| 8 | −EXP = ROOT / "experiments" | |
| 8 | +EXP = BASE_EXP | |
| 9 | 9 | J = lambda n: json.load(open(EXP / n)) if (EXP / n).exists() else {} |
| 10 | 10 | |
| 11 | 11 | |
modified
src/validation/category_audit.py
+4 −4
@@ -13,11 +13,11 @@ from __future__ import annotations | ||
| 13 | 13 | import json, os, sys, time, concurrent.futures as cf |
| 14 | 14 | import pandas as pd |
| 15 | 15 | from dotenv import load_dotenv |
| 16 | −from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json | |
| 16 | +from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json, BASE_PROC, BASE_EXP | |
| 17 | 17 | |
| 18 | 18 | log = get_logger("validation.category_audit") |
| 19 | 19 | load_dotenv(ROOT / ".env") |
| 20 | −SAMPLE = ROOT / "data" / "processed" / "category_audit_sample.csv" | |
| 20 | +SAMPLE = BASE_PROC / "category_audit_sample.csv" | |
| 21 | 21 | TAXO = { |
| 22 | 22 | "erable": "Érable & miel: sirop, beurre, flocons d'érable; miel, pollen, hydromel non alcoolisé", |
| 23 | 23 | "epicerie": "Épicerie fine: confitures, sauces, condiments, épices, vinaigres, huiles, farines, granolas, collations, marinades, produits d'épicerie en général (hors catégories plus spécifiques)", |
@@ -129,9 +129,9 @@ def report(): | ||
| 129 | 129 | conf = pd.crosstab(sub.category, sub.llm_a) |
| 130 | 130 | out["confusion_rule_vs_consensus"] = {r: {c: int(v) for c, v in row.items() if v} for r, row in conf.to_dict("index").items()} |
| 131 | 131 | dis = df[(df.category != df.llm_a) | (df.category != df.llm_b)] |
| 132 | − dis[["uid", "store_id", "category", "llm_a", "llm_b", "title", "product_type"]].to_csv(ROOT / "data" / "processed" / "category_audit_disagreements.csv", index=False) | |
| 132 | + dis[["uid", "store_id", "category", "llm_a", "llm_b", "title", "product_type"]].to_csv(BASE_PROC / "category_audit_disagreements.csv", index=False) | |
| 133 | 133 | out["n_disagreements"] = len(dis) |
| 134 | − write_json(out, ROOT / "experiments" / "category_audit.json", "category_audit.report") | |
| 134 | + write_json(out, BASE_EXP / "category_audit.json", "category_audit.report") | |
| 135 | 135 | log.info(json.dumps({k: v for k, v in out.items() if "per_" not in k and "confusion" not in k})) |
| 136 | 136 | |
| 137 | 137 | |
modified
src/validation/external_validation.py
+3 −3
@@ -4,10 +4,10 @@ from __future__ import annotations | ||
| 4 | 4 | import json |
| 5 | 5 | import numpy as np, pandas as pd |
| 6 | 6 | from scipy import stats |
| 7 | −from src.common import ROOT, get_logger, ledger, write_json | |
| 7 | +from src.common import ROOT, get_logger, ledger, write_json, PROC_DIR, EXP_DIR, P | |
| 8 | 8 | |
| 9 | 9 | log = get_logger("validation.external") |
| 10 | −PROC = ROOT / "data" / "processed" | |
| 10 | +PROC = PROC_DIR | |
| 11 | 11 | |
| 12 | 12 | |
| 13 | 13 | def main(): |
@@ -33,7 +33,7 @@ def main(): | ||
| 33 | 33 | "source_mix_by_region": src.to_dict("index"), "platform_mix_by_region": plat.to_dict("index"), "category_mix_by_platform": catplat.to_dict("index"), |
| 34 | 34 | "note": "Business locations = ISQ, locations with employees, December 2025 (all industries; retail NAICS 44-45; manufacturing 31-33); population = ISQ 1 July 2025 provisional. Fabri-Ka stores are online storefronts of Quebec-based merchants discovered through certification registries, associations and search; coverage ratios are not sampling rates."} |
| 35 | 35 | src.to_csv(PROC / "source_mix_by_region.csv"); plat.to_csv(PROC / "platform_mix_by_region.csv"); catplat.to_csv(PROC / "category_mix_by_platform.csv") |
| 36 | − write_json(out, ROOT / "experiments" / "external_validation.json", "external"); ledger("validation.external", corr=corr, cv=cv) | |
| 36 | + write_json(out, EXP_DIR / "external_validation.json", "external"); ledger("validation.external", corr=corr, cv=cv) | |
| 37 | 37 | log.info(json.dumps({"corr": corr, "coverage": cv, "unknown": out["unknown_region"]}, ensure_ascii=False)) |
| 38 | 38 | |
| 39 | 39 | |
modified
src/validation/match_validation.py
+4 −4
@@ -10,11 +10,11 @@ from __future__ import annotations | ||
| 10 | 10 | import json, sys, time, concurrent.futures as cf |
| 11 | 11 | import pandas as pd |
| 12 | 12 | from dotenv import load_dotenv |
| 13 | −from src.common import ROOT, P, SEED, get_logger, ledger, write_json | |
| 13 | +from src.common import ROOT, P, SEED, get_logger, ledger, write_json, BASE_PROC, BASE_EXP | |
| 14 | 14 | |
| 15 | 15 | log = get_logger("validation.match") |
| 16 | 16 | load_dotenv(ROOT / ".env") |
| 17 | −SAMPLE = ROOT / "data" / "processed" / "match_validation_sample.csv" | |
| 17 | +SAMPLE = BASE_PROC / "match_validation_sample.csv" | |
| 18 | 18 | SYSTEM = ("Tu es un expert en appariement de catalogues. On te montre deux fiches produit provenant de deux boutiques en ligne québécoises " |
| 19 | 19 | "différentes. Décide si elles décrivent le MÊME produit (même marque/fabricant, même article, même format/contenance ; des variantes de " |
| 20 | 20 | "couleur ou de taille du même article comptent comme le même produit si le prix affiché est le prix de base). Deux produits artisanaux " |
@@ -93,8 +93,8 @@ def report(): | ||
| 93 | 93 | if "operator" in df.columns: |
| 94 | 94 | out["precision_by_tier_operator"] = {t: round(float((df.loc[df.tier == t, "operator"] == "yes").mean()), 3) for t in sorted(df.tier.unique())} |
| 95 | 95 | dis = df[(df.llm_a != df.llm_b) | (df.llm_a == "unsure")] |
| 96 | − dis.to_csv(ROOT / "data" / "processed" / "match_validation_disagreements.csv", index=False); out["n_disagreements"] = len(dis) | |
| 97 | − write_json(out, ROOT / "experiments" / "match_validation.json", "match_validation.report"); log.info(json.dumps(out)) | |
| 96 | + dis.to_csv(BASE_PROC / "match_validation_disagreements.csv", index=False); out["n_disagreements"] = len(dis) | |
| 97 | + write_json(out, BASE_EXP / "match_validation.json", "match_validation.report"); log.info(json.dumps(out)) | |
| 98 | 98 | |
| 99 | 99 | |
| 100 | 100 | if __name__ == "__main__": |
modified
src/validation/robustness.py
+4 −4
@@ -5,12 +5,12 @@ recomputed on (i) S_R0, (ii) certification/association sources only, (iii) exclu | ||
| 5 | 5 | from __future__ import annotations |
| 6 | 6 | import json |
| 7 | 7 | import numpy as np, pandas as pd |
| 8 | −from src.common import ROOT, SEED, get_logger, ledger, write_json | |
| 8 | +from src.common import ROOT, SEED, get_logger, ledger, write_json, PROC_DIR, EXP_DIR, P, BASE_PROC, BASE_EXP | |
| 9 | 9 | from src.models.h1_raa import weights, table, cramers_v |
| 10 | 10 | from src.models.h3_longtail import gini |
| 11 | 11 | |
| 12 | 12 | log = get_logger("validation.robustness") |
| 13 | −PROC = ROOT / "data" / "processed" | |
| 13 | +PROC = PROC_DIR | |
| 14 | 14 | MACRO = {"erable": "food", "epicerie": "food", "cafe_the": "food", "chocolat": "food", "boulangerie": "food", "viande_poisson": "food", "fromage_laitier": "food", "boissons": "food", |
| 15 | 15 | "alcool": "alcohol", "sante_beaute": "beauty", "maison": "home", "mode": "fashion", "bijoux": "fashion", "art": "art", "enfants": "kids_pets", "animaux": "kids_pets", |
| 16 | 16 | "plein_air": "outdoor_garden", "jardin": "outdoor_garden", "industriel": "industrial", "autre": "other"} |
@@ -51,7 +51,7 @@ def main(): | ||
| 51 | 51 | for k, d in variants.items(): |
| 52 | 52 | res[k] = {"h1_M": h1(d, rng, "M"), "h1_T": h1(d, rng, "T"), "entropy_M": ent_range(d), "gini_listings": round(gini(d.groupby("store_id").n.sum().values), 4)} |
| 53 | 53 | log.info("%s %s", k, json.dumps(res[k])) |
| 54 | − audit = ROOT / "experiments" / "category_audit.json" | |
| 54 | + audit = BASE_EXP / "category_audit.json" | |
| 55 | 55 | if audit.exists(): |
| 56 | 56 | conf = json.load(open(audit)).get("confusion_rule_vs_consensus", {}) |
| 57 | 57 | if conf: |
@@ -67,7 +67,7 @@ def main(): | ||
| 67 | 67 | d = d.groupby(["store_id", "category", "region_final", "region_source", "first_source", "store_kind"], as_index=False).n.sum() |
| 68 | 68 | Vs.append(h1(d, rng, "M", nperm=200)) |
| 69 | 69 | res["confusion_reassignment_100draws"] = {"V_mean": round(float(np.mean([v["V"] for v in Vs])), 4), "V_min": round(float(min(v["V"] for v in Vs)), 4), "share_p_lt_05": round(float(np.mean([v["p"] < 0.05 for v in Vs])), 3)} |
| 70 | − write_json(res, ROOT / "experiments" / "robustness.json", "robustness"); ledger("validation.robustness", variants=list(res)) | |
| 70 | + write_json(res, EXP_DIR / "robustness.json", "robustness"); ledger("validation.robustness", variants=list(res)) | |
| 71 | 71 | |
| 72 | 72 | |
| 73 | 73 | if __name__ == "__main__": |
| 74 | 74 | |