What you're actually building

Econometrics is statistics with an economic question attached. The core tool, and the one that gets you 80% of the way to understanding everything built on top of it, is ordinary least squares (OLS) regression: fitting a line (or hyperplane, with multiple variables) through your data that minimizes the squared distance between your predictions and reality. Everything else — fixed effects, instrumental variables, panel models — is a variation built to fix a specific way OLS can go wrong.

This guide builds one real model end to end: predicting how much an early-stage startup raises based on observable traits at the time of the raise. The dataset is synthetic (generated in the code below, so you can run every line yourself), but the workflow — fit, check assumptions, fix what's broken, interpret honestly — is identical to what you'd do with real data.

Setup

Three libraries do essentially everything: pandas for data handling, numpy for the maths, statsmodels for the actual regression and its diagnostics (it reports far more statistical detail than scikit-learn, which is built for prediction rather than inference).

pip install pandas numpy statsmodels matplotlib

Step 1 — Get data into a DataFrame

Real data comes from a CSV, an API, or a database query. To make this guide fully reproducible, here's a synthetic dataset with a funding round size that depends on four founder/company traits, plus some realistic random noise:

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 300

df = pd.DataFrame({
    "months_since_founding": rng.uniform(3, 36, n),
    "monthly_revenue_k": rng.gamma(2, 8, n),          # £k/month, right-skewed
    "team_size": rng.integers(2, 25, n),
    "prior_funding_k": rng.gamma(1.5, 40, n),         # £k raised previously
})

# True relationship (unknown in real life -- this is what we're trying to recover)
noise = rng.normal(0, 60, n)
df["raise_size_k"] = (
    30
    + 4.5 * df["months_since_founding"]
    + 8.0 * df["monthly_revenue_k"]
    + 12.0 * df["team_size"]
    + 0.6 * df["prior_funding_k"]
    + noise
).clip(lower=10)  # can't raise a negative amount

print(df.describe())

Step 2 — Fit the model

statsmodels wants an explicit intercept, so you add a constant column before fitting — easy to forget, and it silently changes what your coefficients mean if you do.

import statsmodels.api as sm

X = df[["months_since_founding", "monthly_revenue_k", "team_size", "prior_funding_k"]]
X = sm.add_constant(X)
y = df["raise_size_k"]

model = sm.OLS(y, X).fit()
print(model.summary())

The output you actually care about, reading top to bottom: R-squared (what fraction of the variation in raise size this model explains), each coefficient's estimate (the effect of a one-unit change in that variable, holding the others fixed), and its p-value (roughly, how likely you'd see an effect this large by chance alone if the true effect were zero — under 0.05 is the conventional, if arbitrary, bar).

Step 3 — Check the assumptions before you trust any of it

A regression that runs without errors will still hand you a summary table full of numbers even when its core assumptions are badly violated. Python won't warn you. Three checks catch most real-world problems.

Multicollinearity — when your predictor variables are highly correlated with each other, the model can't cleanly separate their individual effects, and your coefficients become unstable (small changes in the data swing them wildly). The variance inflation factor (VIF) catches this; a common rule of thumb flags anything above 10.

from statsmodels.stats.outliers_influence import variance_inflation_factor

vif_data = pd.DataFrame()
vif_data["variable"] = X.columns
vif_data["VIF"] = [variance_inflation_factor(X.values, i) for i in range(X.shape[1])]
print(vif_data)

Heteroscedasticity — when the size of your prediction errors changes systematically across the range of your data (e.g. the model is far less accurate for large raises than small ones). It doesn't bias your coefficients, but it does make your p-values and confidence intervals wrong. The Breusch-Pagan test gives you a formal check:

from statsmodels.stats.diagnostic import het_breuschpagan

bp_test = het_breuschpagan(model.resid, model.model.exog)
labels = ["LM stat", "LM p-value", "F stat", "F p-value"]
print(dict(zip(labels, bp_test)))

Normality of residuals — OLS's confidence intervals and p-values rely on the errors being roughly normally distributed. A quick visual check beats a formal test here, because it also shows you why it fails, not just that it does:

import matplotlib.pyplot as plt

fig, ax = plt.subplots(1, 2, figsize=(10, 4))
ax[0].hist(model.resid, bins=30)
ax[0].set_title("Residual distribution")
sm.qqplot(model.resid, line="45", ax=ax[1])
ax[1].set_title("Q-Q plot")
plt.tight_layout()
plt.show()

Step 4 — When a check fails

Each of these has a standard fix, and none of them require starting over:

Reading the result honestly

A statistically significant coefficient tells you the relationship is unlikely to be pure noise in this dataset, under the model's assumptions. It does not tell you the relationship is causal, that it will hold in a different market or time period, or that the variables you left out don't matter more than the ones you included. The single most common misuse of a regression like this one is presenting an association as if it were a mechanism — "more team members causes bigger raises" — when the honest reading is closer to "in this sample, bigger raises are associated with larger teams, for reasons this model can't distinguish."

That caveat isn't a reason to skip the analysis. A well-specified, honestly-interpreted regression is still one of the sharpest tools available for turning a pile of numbers into a testable claim about how something works — which is exactly why it's worth learning to do properly rather than pasting into a black-box AutoML tool and trusting whatever comes out.