Econometric Analysis · Python · OLS Regression

Market Revenue Prediction
for New European Markets

A full econometric investigation into which European markets to enter next — starting from a naive KNIME model that predicted negative revenue for Norway, through four model iterations, to a statistically rigorous log-log OLS with HAC-corrected standard errors.

statsmodels OLS HAC / Newey-West Log-Log Regression Durbin-Watson Breusch-Pagan Shapiro-Wilk Ridge Regression KNIME BigQuery Decision Tree

Model Iteration Log

Version
Model
DW stat
R²
Outcome
v0 · KNIME
Linear Regression, 6 training countries, raw GDP + population
—
—
Negative revenue for Norway. Too few data points.
v1 · Python
Level OLS, 21 countries, GDP + population + stores
0.664
0.81
Strong autocorrelation. High multicollinearity (Cond.No.=922).
v2
Level OLS + HAC standard errors (Newey-West, maxlags=1)
0.664
0.81
SEs corrected. GDP & population significant. Negative store coef.
v3
Per-capita OLS + HAC (revenue/pop, gdp/pop, stores/pop)
1.425
~0.81
DW improved. But store_per_capita coef still negative (p=0.002).
v4 · Final
Log-Log OLS + HAC · per-capita features
1.195
0.346
Cond.No.=32.6. All predictors significant. Interpretable elasticities.

Where should the company expand next?

The company operates in 21 European and global markets. Four candidate markets have been identified — Norway, Czech Republic, Poland, and Portugal — and the question is: how much revenue can we realistically expect from each?

The dataset combines internal sales data from BigQuery (go_explore_project.daily_sales joined to retailers) with external macroeconomic indicators: GDP in EUR billions and population in millions.

The starting point: A KNIME linear regression trained on only 6 European countries (Sweden, Austria, Germany, France, Spain, Denmark) predicted negative revenue for Norway. Norway's GDP per capita of €105,454 sat far above any training example — the model extrapolated outside its range and produced a nonsense result.
The KNIME Linear Regression Model
CountryPopulation (M)GDP (B EUR)GDP per capitaMarket
Norway5.5580105,454Target
Czech Republic10.833030,556Target
Poland36.886023,370Target
Portugal10.427025,962Target
Sweden10.559056,190Existing
Germany84.04,50053,571Existing
Denmark5.940067,797Existing

The fix was to expand the training set to 21 countries and move to Python with statsmodels for proper statistical testing.

Why four model versions were needed

v1 — Level OLS: R² of 0.81 looked impressive but the Durbin-Watson of 0.664 revealed severe positive autocorrelation — residuals were not independent. The condition number of 922 also flagged multicollinearity between GDP and population (both large for large countries).

0.664Durbin-Watson v1
922Condition Number v1
0.81R² v1 (misleading)

v2 — HAC correction: Newey-West standard errors with maxlags=1 corrected the standard error inflation from autocorrelation, making the significance tests trustworthy. GDP (p=0.025) and population (p=0.002) became clearly significant. But the store count coefficient was negative — counterintuitive.

HAC Standard Errors — Newey-West correction
ols_model = sm.OLS(y_train, X_train_sm).fit( cov_type='HAC', cov_kwds={'maxlags': 1} # Newey-West with 1 lag )

v3 — Per-capita transformation: Converting all variables to per-capita (dividing by population in millions) reduced multicollinearity — Condition Number dropped. DW improved to 1.425. But store_per_capita remained negative and highly significant (p=0.002), suggesting a real economic effect rather than a data artifact.

v4 — Log-Log transformation: Taking the natural log of all variables and the target linearises the multiplicative relationship between economic scale and revenue. This is standard practice in cross-country economic analysis. Condition Number dropped to 32.6. Both predictors remained significant. R² fell from 0.81 to 0.35 — but this is not a regression: the level model was fitting scale, not economic relationships.

Why R² dropped from 0.81 to 0.35: The level model's high R² was driven by the fact that large countries (USA, Germany) dominate the variance. The log-log model fits the proportional relationship equally across all countries — a more honest fit for cross-country analysis. The F-statistic p-value of 0.022 confirms the model is globally significant.

Log-Log OLS with HAC standard errors

Final model specification
# Feature engineering df['gdp_per_capita'] = df['GDP_EUR_billion'] / df['population_million'] df['store_per_capita'] = df['total_active_stores'] / df['population_million'] # Log-transform everything X_train_log = np.log(train[['gdp_per_capita', 'store_per_capita']]) y_train_log = np.log(train['total_revenue']) # OLS with HAC-robust standard errors X_sm = sm.add_constant(X_train_log) ols_model = sm.OLS(y_train_log, X_sm).fit( cov_type='HAC', cov_kwds={'maxlags': 1} ) # Predict → convert back from log space log_preds = ols_model.predict(sm.add_constant(X_test_log)) predictions = np.exp(log_preds)
Intercept: baseline log-revenue
gdp_per_capita coef ≈ +0.96 (p=0.011) → 1% ↑ GDP per capita = ~0.96% ↑ revenue
store_per_capita coef ≈ −0.52 (p=0.008) → diminishing returns to retail density
In log-log models, coefficients ARE elasticities — no unit conversion needed.

The negative store_per_capita coefficient is economically meaningful, not a bug. Markets with more stores per person are more saturated — each additional store generates less marginal revenue. The sensitivity analysis confirms this: opening more stores does increase total revenue, but at a decreasing rate per store.

Assumption test results

AssumptionTestResultNotes
LinearityResiduals vs Fitted✅ PASSRandom scatter around zero
NormalityShapiro-Wilk✅ PASSp > 0.05
IndependenceDurbin-Watson⚠ PARTIALDW=1.195 — HAC SEs applied
HomoscedasticityBreusch-Pagan✅ PASSp > 0.05, constant variance

Predicted revenue — 4 new markets (1 store baseline)

🇳🇴 Norway
9.3M EUR
🇵🇱 Poland
9.0M EUR
🇨🇿 Czech Rep.
5.4M EUR
🇵🇹 Portugal
4.7M EUR
Norway is the highest-potential target market despite its small population — because its GDP per capita is the highest of the four (€105K vs Poland's €23K). The model's near-unit elasticity on GDP per capita drives this result.

Store count sensitivity

Since the test data uses 1 store as baseline, the model was re-run for scenarios of 1–10 stores to inform the expansion decision:

Country1 store2 stores3 stores5 stores10 stores
🇳🇴 Norway 9.3M6.2M4.9M3.6M2.4M
🇨🇿 Czech Rep. 5.4M3.6M2.8M2.1M1.4M
🇵🇱 Poland 9.0M6.0M4.7M3.5M2.3M
🇵🇹 Portugal 4.7M3.1M2.4M1.8M1.2M
Interpretation: The per-store revenue decreases as stores increase — consistent with the negative store_per_capita elasticity. Opening more stores cannibalises each other's catchment. A single flagship store per new market is the model's implied recommendation before market penetration data is available.

Can a decision tree recommend store count instead of revenue?

The log-log OLS model answers how much revenue to expect from each new market. A natural follow-up is how many stores to open. A decision tree was tested as a second, independent method — first as a regressor, then, after it failed outright, repurposed as a classifier for store-count tiers.

First attempt: revenue regression failed

A DecisionTreeRegressor trained directly on revenue produced a textbook overfitting signature — a perfect fit on training data and no predictive power on unseen data.

1.00 R² — Train
~0.00 R² — Test
21 Total Rows
Diagnosis: with 21 countries and only 2–3 features, an unconstrained tree splits until every leaf holds a single country — perfect memorization, not a generalizable pattern. Same small-sample issue as the early OLS iterations, but trees are far more sensitive to it than linear models.

Pivot: classification by store tier

Rather than predicting an exact revenue figure, the tree was repurposed to classify markets into store-count tiers — a coarser target better suited to a small sample. Existing markets were grouped (small: 5–9, medium: 10–17, large: 18+), and a constrained classifier was fit on gdp_per_capita and population_million.

store_tier_classifier.py
from sklearn.tree import DecisionTreeClassifier classifier = DecisionTreeClassifier( max_depth=2, min_samples_leaf=3, random_state=1234 ) model = classifier.fit(X, y) # y = store_tier, fit on the 21 known markets targets['predicted_tier'] = model.predict(targets[['gdp_per_capita', 'population_million']])
Decision tree splitting rules for store tier classification
Decision tree trained on 21 existing markets — splits on GDP per capita and population.
CountryGDP per capitaPopulation (M)Predicted Tier
🇳🇴 Norway105,4545.5small (5–9)
🇨🇿 Czech Republic30,55610.8small (5–9)
🇵🇱 Poland23,37036.8small (5–9)
🇵🇹 Portugal25,96210.4small (5–9)

All four candidate markets land in the smallest tier — a conservative result that agrees directionally with the OLS sensitivity analysis, which also recommended a single flagship store ahead of broader rollout, given the negative store_per_capita elasticity (retail saturation).

Sensitivity testing

Before trusting this result, the tree was re-fit across a range of max_depth and min_samples_leaf settings, and across an alternative, finer-grained 4-tier binning scheme.

SettingTier SchemeResult — All 4 TargetsStability
depth 1–3, leaf 3–5 3-tier (original) small (5–9) — unchanged Stable
depth 2–3, leaf 1 4-tier (finer) xsmall / small — split by country Sensitive
depth 2–3, leaf 2–3 4-tier (finer) large (20+) — all four Unstable
depth 2–3, leaf 4 4-tier (finer) xsmall (5–7) — all four Unstable
Finding: under the coarser 3-tier scheme, the classification was stable across every hyperparameter combination tested. Under the finer 4-tier scheme, the prediction for all four countries swung between the smallest and the largest available tier depending on a single hyperparameter — a clear sign of overfitting noise rather than signal, given only 21 training rows. Feature importance was similarly unstable, ranging from roughly 55/45 to 82/18 between population and GDP per capita depending on configuration.
Interpretation: the decision tree is not robust enough to support a precise, fine-grained recommendation. Read as a coarse, directional signal, it agrees with the OLS-based finding that all four candidate markets warrant a conservative initial footprint. It should not be read as ranking the four markets against each other — the OLS revenue estimates (Norway 9.3M, Poland 9.0M vs. Czech Republic 5.4M, Portugal 4.7M) remain the more granular basis for that comparison.

What this investigation taught

📉

High R² is not the goal

The level model had R²=0.81 but was econometrically invalid. The log-log model's R²=0.35 is more honest and actually useful for prediction.

🔁

Autocorrelation in cross-sectional data is common

Countries are not independent observations — they share economic history, trade links, and geography. HAC standard errors (Newey-West) are standard practice for cross-country regressions.

📐

Log-log is natural for economic scale variables

GDP and revenue span orders of magnitude across countries. A log-log model fits proportional relationships and produces elasticity coefficients — directly interpretable without unit conversion.

🏪

A negative coefficient can be the finding

The negative store_per_capita elasticity is not a flaw — it's evidence of retail saturation. It directly informs the recommendation to open single flagship stores rather than multi-store rollouts.

🔬

Document the dead ends

Showing v0 through v4 with their failure modes is more valuable portfolio content than presenting only the final answer. It demonstrates diagnostic thinking, not just tool use.

🌳

Small-sample fragility is itself a finding

The decision tree's classification flipped between the smallest and largest tier under minor hyperparameter changes when using finer tier bins. Diagnosing and reporting that instability — rather than cherry-picking a favorable run — is the more rigorous outcome.

Tools & methods used

Tool / MethodRole
Google BigQuerySource data — daily_sales × retailers join for training revenue
KNIME Analytics Platformv0 prototype — exposed the need for more training data
statsmodels OLSPrimary model with HAC-robust standard errors
HAC / Newey-WestAutocorrelation-robust standard error correction
Shapiro-WilkNormality test on residuals
Durbin-WatsonIndependence / autocorrelation detection
Breusch-PaganHomoscedasticity test
Ridge Regression (RidgeCV)Robustness check — confirmed OLS direction
Python / JupyterFull analysis pipeline
Decision treeRegression and classification model

Additional Resources

Here you can download the Jupyter notebooks and the train and test datasets for this project:

Completion Certificate

Certificate Anaconda Pandas for Data Science

Completion Certificate

Certificate Anaconda Pandas for Machine Learning