Probabilistic Modeling: Fitting Distributions to Data

Probability as a Modeling Tool

So far in this course we have clustered data by measuring distances between points.

Today we start treating data as the outcome of a random process and ask a different question:

  • What probability distribution could have generated this data?
  • Once we pick a family of distributions (a model), we fit it – estimate its parameters from data.
  • Then we check whether the fitted model actually describes the data.
  • And we use the fitted model to say things: probabilities of events, how two variables move together, how uncertain a sample mean is.

This is not a probability refresher. We will introduce each distribution as we need it to model a specific dataset.

For definitions, rules of probability, Bayes’ theorem, and the full catalog of named distributions, see the Probability and Statistics Refresher.

Fitting a Distribution to Data

A First Dataset: Boston Daily Temperatures

We will use daily temperature records for Boston Logan International Airport from NOAA, station USW00014739, going back to 1936.

For each day we have the maximum and minimum temperature. We take the daily mean temperature to be their average.

Download Boston daily temperatures from NOAA (helper)
def download_boston_temperatures(years=100):
    """
    Download daily TMAX/TMIN for Boston Logan (USW00014739) from the NOAA
    Climate Data Online API and return a DataFrame with a daily mean temperature
    TMEAN = (TMAX + TMIN) / 2 in degrees Celsius.
    """
    end_date = datetime.now()
    start_date = end_date - timedelta(days=365 * years)
    params = {
        'dataset': 'daily-summaries',
        'stations': 'USW00014739',
        'startDate': start_date.strftime("%Y-%m-%d"),
        'endDate': end_date.strftime("%Y-%m-%d"),
        'dataTypes': 'TMAX,TMIN',
        'format': 'json',
        'units': 'metric'
    }
    response = requests.get("https://www.ncei.noaa.gov/access/services/data/v1",
                            params=params, timeout=60)
    response.raise_for_status()
    df = pd.DataFrame(response.json())
    df['DATE'] = pd.to_datetime(df['DATE'])
    for col in ['TMAX', 'TMIN']:
        df[col] = pd.to_numeric(df[col], errors='coerce')
    df['TMEAN'] = (df['TMAX'] + df['TMIN']) / 2
    df['MONTH'] = df['DATE'].dt.month
    df['YEAR'] = df['DATE'].dt.year
    return df.dropna(subset=['TMEAN'])

temps = download_boston_temperatures()
print(f"{len(temps):,} daily records, {temps['YEAR'].min()} - {temps['YEAR'].max()}")
temps[['DATE', 'TMAX', 'TMIN', 'TMEAN']].head()
33,099 daily records, 1936 - 2026
DATE TMAX TMIN TMEAN
0 1936-01-01 1.7 -6.1 -2.20
1 1936-01-02 1.7 -6.1 -2.20
2 1936-01-03 12.2 1.7 6.95
3 1936-01-04 7.8 1.7 4.75
4 1936-01-05 6.1 0.6 3.35

Look at the Data First

Let’s pull out all the July days – roughly 90 years times 31 days – and look at how the daily mean temperature is distributed.

Code
month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
               'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']

july = temps[temps['MONTH'] == 7]['TMEAN']

plt.figure(figsize=(7, 4))
plt.hist(july, bins=40, density=True, alpha=0.6, edgecolor='black')
plt.title(f'Boston daily mean temperature in July ({len(july):,} days)', size=14)
plt.xlabel('Temperature (°C)', size=12)
plt.ylabel('Density', size=12)
plt.show()

The histogram is unimodal, roughly symmetric, and bell-shaped.

That suggests a model: treat each July day’s mean temperature as a draw from a Gaussian random variable.

The Model: The Gaussian Distribution

The Gaussian (or Normal) distribution is the workhorse of probabilistic modeling.

A Gaussian random variable \(X \sim \mathcal{N}(\mu, \sigma^2)\) has probability density function

\[ p_{\mu,\sigma}(x) = \frac{1}{\sigma \sqrt{2 \pi}} \, e^{-\frac{1}{2}\left(\frac{x-\mu}{\sigma}\right)^2}. \]

It has exactly two parameters:

  • \(\mu\), the mean – where the bell is centered, and
  • \(\sigma^2\), the variance (\(\sigma\) is the standard deviation) – how wide the bell is.

Why do we reach for it so often? Because sums of many small independent effects tend to be Gaussian – we will make this precise (the Central Limit Theorem) at the end of the lecture.

Fitting the Model

A Gaussian model for July temperatures is fully specified once we choose \(\mu\) and \(\sigma\).

The natural choice: set \(\mu\) to the sample mean and \(\sigma\) to the sample standard deviation of the observed July days.

Code
mu_hat = july.mean()
sigma_hat = july.std()
print(f"Fitted Gaussian for July:  mu = {mu_hat:.2f} °C,  sigma = {sigma_hat:.2f} °C")
Fitted Gaussian for July:  mu = 23.23 °C,  sigma = 3.17 °C

Right now “the natural choice” is a heuristic. In the second part of the lecture we will see that these are precisely the maximum likelihood estimates – the parameters that make the observed data most probable.

Checking the Fit

A model that we don’t check is just an assumption. The simplest check is visual: overlay the fitted density on the histogram.

Code
plt.figure(figsize=(7, 4))
plt.hist(july, bins=40, density=True, alpha=0.6, edgecolor='black', label='Observed (histogram)')
xs = np.linspace(july.min() - 2, july.max() + 2, 300)
plt.plot(xs, norm.pdf(xs, mu_hat, sigma_hat), 'r-', lw=3,
         label=f'Fitted Gaussian\n$\\mu$={mu_hat:.1f}, $\\sigma$={sigma_hat:.1f}')
plt.title('Boston July daily mean temperature: data vs. fitted Gaussian', size=14)
plt.xlabel('Temperature (°C)', size=12)
plt.ylabel('Density', size=12)
plt.legend()
plt.show()

Not perfect – the histogram is lumpier than the smooth curve, and slightly asymmetric – but a Gaussian is a reasonable description of July.

Checking the Fit Quantitatively

A Gaussian makes concrete predictions we can compare against the data: about 68% of values should fall within \(\mu \pm \sigma\) and about 95% within \(\mu \pm 2\sigma\).

Code
for k in [1, 2, 3]:
    lo, hi = mu_hat - k * sigma_hat, mu_hat + k * sigma_hat
    observed = ((july >= lo) & (july <= hi)).mean()
    predicted = norm.cdf(k) - norm.cdf(-k)
    print(f"within {k} sigma:  observed {observed:.3f}   Gaussian predicts {predicted:.3f}")
within 1 sigma:  observed 0.664   Gaussian predicts 0.683
within 2 sigma:  observed 0.966   Gaussian predicts 0.954
within 3 sigma:  observed 0.999   Gaussian predicts 0.997

Close agreement – the model captures the spread of the data well.

Does the Same Model Fit Every Month?

Fitting is cheap, so let’s fit a separate Gaussian to each month and look at four of them.

Code
fig, axes = plt.subplots(1, 4, figsize=(14, 3.5), sharey=True)
for ax, m in zip(axes, [1, 4, 7, 10]):
    data = temps[temps['MONTH'] == m]['TMEAN']
    mu_m, sd_m = data.mean(), data.std()
    ax.hist(data, bins=35, density=True, alpha=0.6, edgecolor='black')
    xs = np.linspace(data.min() - 2, data.max() + 2, 200)
    ax.plot(xs, norm.pdf(xs, mu_m, sd_m), 'r-', lw=2.5)
    ax.set_title(f'{month_names[m-1]}: $\\mu$={mu_m:.1f}, $\\sigma$={sd_m:.1f}')
    ax.set_xlabel('°C')
plt.suptitle('One Gaussian per month', size=14)
plt.tight_layout()
plt.show()

Notice two things: (1) the parameters differ a lot – winter months are colder and more variable; (2) the fit is visibly worse in April (a longer warm tail, with the peak sitting left of the fitted mean). Checking the fit tells us where the model is trustworthy.

The Whole Year at a Glance

Plotting every day, plus each month’s fitted \(\mu \pm \sigma\), summarizes the twelve fitted models in one picture.

Scatter of all days with monthly mean ± 1 std
monthly_stats = temps.groupby('MONTH')['TMEAN'].agg(['mean', 'std', 'count'])

plt.figure(figsize=(10, 6))
rng = np.random.default_rng(0)
for month in range(1, 13):
    month_data = temps[temps['MONTH'] == month]['TMEAN']
    x_jitter = rng.normal(month, 0.15, len(month_data))
    plt.scatter(x_jitter, month_data, alpha=0.3, s=12,
                color=plt.cm.viridis(month / 12), edgecolors='black', linewidth=0.3)

plt.errorbar(range(1, 13), monthly_stats['mean'], yerr=monthly_stats['std'],
             fmt='ro', ecolor='red', capsize=5, capthick=2, markersize=8,
             markeredgecolor='darkred', linewidth=2, label='Fitted $\\mu \\pm 1\\sigma$')
plt.title('Boston daily mean temperature by month\n(all days, with fitted mean ± standard deviation)',
          size=14, fontweight='bold')
plt.xlabel('Month', size=12)
plt.ylabel('Temperature (°C)', size=12)
plt.xticks(range(1, 13), month_names)
plt.grid(True, alpha=0.3)
plt.legend(loc='upper right')
plt.show()

What a Fitted Model Buys You

Once we trust the model, we can ask questions the raw data answers only clumsily.

For example: what is the probability that a July day in Boston has a mean temperature above 30 °C?

Code
p_model = norm.sf(30, mu_hat, sigma_hat)        # sf = 1 - cdf, the upper tail
p_data = (july > 30).mean()
print(f"Gaussian model:  P(T > 30 °C) = {p_model:.4f}")
print(f"Empirical:       fraction of July days above 30 °C = {p_data:.4f}")
Gaussian model:  P(T > 30 °C) = 0.0165
Empirical:       fraction of July days above 30 °C = 0.0117

Same order of magnitude, with the model somewhat overstating the tail (the data’s warm tail is a bit lighter than Gaussian). For rarer events (say, above 33 °C) the model still gives an answer even where the data has only a handful of cases – with the caveat that a fit is always least trustworthy in the tails.

Fitting a Model to Counts

A Second Dataset: Deaths by Horse Kick

Ladislaus Bortkiewicz1 Law of Small Numbers Book2

  • Ladislaus Bortkiewicz (1868 – 1931)
  • Wrote book “Law of Small Numbers” in 1898
  • Studied the number of Prussian cavalry soldiers killed by horse kicks, per army corps per year, 1875 – 1894
  • Some years a corps had 3 or 4 such deaths – was something going on, or was this what randomness looks like?

The Horse-Kick Data

Twenty years of counts for fourteen corps (the data is in data/HorseKicks.txt).

Code
horse_kicks = pd.read_csv('data/HorseKicks.txt', sep='\t', index_col='Year')
horse_kicks.head(8)
GC C1 C2 C3 C4 C5 C6 C7 C8 C9 C10 C11 C14 C15
Year
1875 0 0 0 0 0 0 0 1 1 0 0 0 1 0
1876 2 0 0 0 1 0 0 0 0 0 0 0 1 1
1877 2 0 0 0 0 0 1 1 0 0 1 0 2 0
1878 1 2 2 1 1 0 0 0 0 0 1 0 1 0
1879 0 0 0 1 1 2 2 0 1 0 0 2 1 0
1880 0 3 2 1 1 1 0 0 0 2 1 4 3 0
1881 1 0 0 2 1 0 0 1 0 1 0 0 0 0
1882 1 2 0 0 0 0 1 0 1 1 2 1 4 1

Following Bortkiewicz’s classic analysis, we drop the four corps with atypical organization (the Guard Corps GC and corps C1, C6, C11), leaving 10 corps \(\times\) 20 years \(= 200\) corps-years.

Each observation is a small non-negative count. A Gaussian is the wrong shape for that. We need a different model.

The Model: The Poisson Distribution

The Poisson distribution models “how many events occur in a fixed interval” when events happen at a constant average rate but otherwise at random.

Definition. A random variable \(X\) taking values \(0, 1, 2, \ldots\) has a Poisson distribution with parameter \(\lambda > 0\) if

\[ p(k) = P(X = k) = \frac{\lambda^k e^{-\lambda}}{k!} \quad \text{for } k = 0, 1, 2, \ldots \]

It has a single parameter \(\lambda\), and both its mean and its variance equal \(\lambda\).

Other classic Poisson-shaped quantities: misprints per page, calls arriving at a call center per minute, radioactive decays per second.

Fitting the Poisson

With one parameter there is only one thing to estimate: the rate \(\lambda\). The natural estimate is the sample mean count.

Code
counts = horse_kicks.drop(columns=['GC', 'C1', 'C6', 'C11']).values.ravel()
lam_hat = counts.mean()
print(f"{len(counts)} corps-years,  {counts.sum()} deaths,  lambda_hat = {lam_hat:.3f} deaths per corps per year")
200 corps-years,  122 deaths,  lambda_hat = 0.610 deaths per corps per year

(As with the Gaussian mean, this is the maximum likelihood estimate – coming up shortly.)

Checking the Fit: Observed vs. Predicted Counts

If the Poisson model is right, then out of 200 corps-years we expect \(200 \cdot p(k)\) of them to have exactly \(k\) deaths.

Code
ks = np.arange(0, 7)
observed = np.bincount(counts, minlength=len(ks))[:len(ks)]
predicted = len(counts) * poisson.pmf(ks, lam_hat)

fit_table = pd.DataFrame({'Observed Instances': observed,
                          'Predicted Instances (Poisson)': predicted.round(2)},
                         index=pd.Index(ks, name='Number of Deaths Per Year'))
fit_table
Observed Instances Predicted Instances (Poisson)
Number of Deaths Per Year
0 109 108.67
1 65 66.29
2 22 20.22
3 3 4.11
4 1 0.63
5 0 0.08
6 0 0.01

Checking the Fit: Observed vs. Predicted Counts

Code
fit_table.plot.bar(figsize=(7, 4))
plt.xlabel("Number of Deaths Per Year", size=14)
plt.ylabel("Count (out of 200 corps-years)", size=14)
plt.xticks(rotation=0)
plt.show()

The agreement is remarkable. The years with 3 or 4 deaths are not evidence of anything unusual – they are exactly what a constant, random rate of \(0.61\) per year produces over 200 tries.

Checking the Fit: A Property Check

The Poisson has a built-in consistency check: its mean and variance are both \(\lambda\). Does the data agree?

Code
print(f"sample mean     = {counts.mean():.3f}")
print(f"sample variance = {counts.var():.3f}")
sample mean     = 0.610
sample variance = 0.608

Yes. When counts show variance much larger than the mean (“overdispersion”), that is a signal the Poisson is the wrong model.

The Pattern So Far

Both examples followed the same four steps:

  1. Look at the data and choose a family of distributions whose shape matches (bell-shaped and continuous \(\to\) Gaussian; small counts \(\to\) Poisson).
  2. Estimate the parameters from the data (\(\hat\mu, \hat\sigma\) or \(\hat\lambda\)).
  3. Check the fit: overlay density on histogram, compare predicted and observed counts, check the model’s implied properties.
  4. Use the model to answer questions.

Step 2 was done by “the natural choice” both times. Now let’s justify it – and get a recipe that works for any model.

Maximum Likelihood Estimation (MLE)

Motivation

Probability distributions are specified by their parameters.

The Gaussian distribution is determined by the parameters \(\mu\) and \(\sigma^{2}\), i.e.,

\[ \begin{align*} f(x\vert\mu, \sigma^{2}) & = \mathcal{N}(x\vert \mu, \sigma^{2}) \\ & = \frac{1}{\sigma\sqrt{2\pi}}e^{-\frac{(x-\mu)^{2}}{2\sigma^2}}. \end{align*} \]

Maximum likelihood estimation is a method to estimate the parameters of a probability distribution given a sample of observed data that best fits the data.

Likelihood Function

The likelihood function \(L(\boldsymbol{\theta}, x)\) represents the probability of observing the given data \(x\) as a function of the parameters \(\boldsymbol{\theta}\) of the distribution.

The primary purpose of the likelihood function is to estimate the parameters that make the observed data \(x\) most probable.

The likelihood function for a set of samples \(x_{n}~\text{for}~n=1, \ldots, N\) drawn from an independent and identically distributed (i.i.d.) Gaussian distribution is

\[ L(\mu, \sigma^{2}, x_1, \ldots, x_n) \ = \prod_{n=1}^{N}\mathcal{N}(x_n\vert \mu, \sigma^{2}) \ = \prod_{n=1}^{N}\frac{1}{\sigma\sqrt{2\pi}}e^{-\frac{(x_n-\mu)^{2}}{2\sigma^2}}. \]

The product comes from independence: the probability of seeing all \(N\) samples is the product of the probabilities of seeing each one.

Maximizing the Likelihood

For a particular set of parameters \(\mu, \sigma^{2}\)

  • large values of \(L(\mu, \sigma^{2}, x_1, \ldots, x_n)\) indicate the observed data is very probable (high likelihood) and thus well modeled by the parameters
  • small values of \(L(\mu, \sigma^{2}, x_1, \ldots, x_n)\) indicate the observed data is very improbable (low likelihood) and thus poorly modeled by the parameters

The parameters that maximize the likelihood function are called the maximum likelihood estimates.

Log-likelihood

A common manipulation to obtain a more useful form of the likelihood function is to take its natural logarithm.

Advantages of the log-likelihood:

  • The log function is monotonically increasing, so the MLE is the same as the log-likelihood estimate
  • The product of probabilities becomes a sum of logarithms, which is more numerically stable
  • The log-likelihood is easier to work with

Applying the Log

\[ \begin{align*} \ell(\mu, \sigma^{2}, x_1, \ldots, x_n) \ & = \log{\left(L(\mu, \sigma^{2}, x_1, \ldots, x_n)\right)} \\ & = \log{\left(\prod_{n=1}^{N}\frac{1}{\sqrt{2\pi\sigma^2}}e^{-\frac{(x_n-\mu)^{2}}{2\sigma^2}}\right)} \\ & = \sum_{n=1}^{N}\log{\left(\frac{1}{\sqrt{2\pi\sigma^2}}e^{-\frac{(x_n-\mu)^{2}}{2\sigma^2}}\right)} \\ & = \sum_{n=1}^{N}\left(\log{\left(\frac{1}{\sqrt{2\pi\sigma^2}}\right)} + \log{\left(e^{-\frac{(x_n-\mu)^{2}}{2\sigma^2}}\right)}\right) \\ & = -\frac{N}{2}\log{(2\pi\sigma^2)} - \frac{1}{2\sigma^{2}}\sum_{n=1}^{N}(x_n -\mu)^{2}. \end{align*} \]

Using the log-likelihood we will be able to derive formulas for the maximum likelihood estimates.

Seeing the Log-likelihood on Real Data

Before deriving anything, let’s just compute the Gaussian log-likelihood of the July temperatures for a range of candidate means \(\mu\) (holding \(\sigma\) at the sample value).

Code
mus = np.linspace(mu_hat - 3, mu_hat + 3, 200)
loglik = [norm.logpdf(july, m, sigma_hat).sum() for m in mus]

plt.figure(figsize=(7, 4))
plt.plot(mus, loglik, lw=2.5)
plt.axvline(mu_hat, color='r', linestyle='--', label=f'sample mean = {mu_hat:.2f}')
plt.xlabel('candidate $\\mu$ (°C)', size=12)
plt.ylabel('log-likelihood $\\ell(\\mu)$', size=12)
plt.title('Log-likelihood of the July data as a function of $\\mu$', size=14)
plt.legend()
plt.show()

The log-likelihood peaks exactly at the sample mean. That is not a coincidence – let’s show why.

Maximizing the log-likelihood

How do we maximize (optimize) a function of parameters?

To find the optimal parameters of a function, we compute partial derivatives of the function and set them equal to zero. The solution to these equations gives us a local optimal value for the parameters.

For the case of the Gaussian we compute

\[ \begin{align*} \nabla_{\mu} \ell(\mu, \sigma^{2}, x_1, \ldots, x_n) &= 0, \\ \nabla_{\sigma} \ell(\mu, \sigma^{2}, x_1, \ldots, x_n) &= 0. \\ \end{align*} \]

For example, differentiating with respect to \(\mu\):

\[ \frac{\partial \ell}{\partial \mu} = \frac{1}{\sigma^2}\sum_{n=1}^{N}(x_n - \mu) = 0 \quad\Longrightarrow\quad \sum_{n=1}^{N} x_n = N\mu \quad\Longrightarrow\quad \hat\mu = \frac{1}{N}\sum_{n=1}^{N} x_n . \]

Gaussian MLEs

The maximum log-likelihood estimates for a Gaussian distribution are given by

\[ \begin{align*} \hat{\mu} &= \frac{1}{N}\sum_{n=1}^{N} x_{n}, \\ \hat{\sigma}^2 &= \frac{1}{N}\sum_{n=1}^{N}(x_{n} - \hat{\mu})^{2}. \end{align*} \]

The full derivation of these results is provided here.

Tip: Try deriving these results yourself!

So “the natural choice” we made for the temperatures – sample mean and sample standard deviation – is the maximum likelihood fit. (The MLE variance divides by \(N\); pandas .std() divides by \(N-1\). For \(N\) in the thousands the difference is negligible.)

The Same Recipe for the Poisson

Nothing about the recipe was specific to the Gaussian. For counts \(k_1, \ldots, k_N\) modeled as i.i.d. Poisson(\(\lambda\)):

\[ \ell(\lambda) = \sum_{n=1}^{N} \log\frac{\lambda^{k_n} e^{-\lambda}}{k_n!} = \log\lambda \sum_{n=1}^{N} k_n \;-\; N\lambda \;-\; \sum_{n=1}^{N}\log k_n! \]

Differentiate and set to zero:

\[ \frac{d\ell}{d\lambda} = \frac{1}{\lambda}\sum_{n=1}^{N} k_n - N = 0 \quad\Longrightarrow\quad \hat\lambda = \frac{1}{N}\sum_{n=1}^{N} k_n . \]

The MLE for the Poisson rate is the sample mean count – exactly what we used for the horse kicks.

Summary of MLE

The recipe: write down the probability of the observed data as a function of the parameters (the likelihood), take the log, and find the parameters that maximize it.

For samples \(x_{1}, \ldots, x_n\) from a Gaussian, this means maximizing

\[ \ell (\mu, \sigma^{2}, x_1, \ldots, x_n) = -\frac{N}{2}\log{2\pi} - N\log{\sigma} - \frac{1}{2\sigma^{2}}\sum_{n=1}^{N}(x_n -\mu)^{2}, \]

giving

\[ \hat{\mu} = \frac{1}{N}\sum_{n=1}^{N} x_{n}, \qquad \hat{\sigma}^2 = \frac{1}{N}\sum_{n=1}^{N}(x_{n} - \hat{\mu})^{2}. \]

You will see MLE again. Gaussian Mixture Models (next lecture) maximize a likelihood where the parameters of several Gaussians are unknown at once, and logistic regression (later) is MLE for a model of class probabilities.

Two Variables at Once

Stocks as Random Variables

So far each model described a single quantity. Very often we care about how two quantities vary together.

Let’s take the daily closing prices of Tesla and NVIDIA in 2023, and look at their 30-day returns:

Code
import yfinance as yf

stocks = ['TSLA', 'NVDA']
df = pd.DataFrame()
for s in stocks:
    df[s] = pd.DataFrame(yf.download(s, start='2023-01-01', end='2023-12-31', progress = False))['Close']

rets = df.pct_change(30)
rets[['TSLA', 'NVDA']].plot(lw=2)
plt.legend(loc='best')
plt.show()

Treating these two time-series as random variables, we are interested in how they vary together.

Covariance

This is captured by the concept of covariance.

Definition. For two random variables \(X\) and \(Y\), their covariance is defined as:

\[ \text{Cov}(X,Y) = E\left[(X-\mu_X)(Y-\mu_Y)\right]. \]

If covariance is positive, this tells us that \(X\) and \(Y\) tend to both be above their means together and both below their means together.

We will often denote \(\text{Cov}(X,Y)\) as \(\sigma_{XY}\).

Note that \(\text{Cov}(X, X) = E[(X-\mu_X)^2] = \sigma_X^2\) – the covariance of a variable with itself is its variance.

Correlation

If we are interested in asking “how similar” are two random variables, we want to normalize covariance by the amount of variance shown by the random variables.

The tool for this purpose is correlation, i.e., normalized covariance:

\[ \rho(X,Y) = \frac{E\left[(X-\mu_X)(Y-\mu_Y)\right]}{\sigma_X \sigma_Y}. \]

\(\rho(X, Y)\) takes on values between -1 and 1.

If \(\rho(X, Y) = 0\) then \(X\) and \(Y\) are uncorrelated.

Note

Note that this is not the same thing as being independent! It just means there is no linear relationship between the two.

But independence implies uncorrelated. Plug in equations and check.

\(\rho\) is sometimes called “Pearson \(r\)” after Karl Pearson who popularized it.

Stock Covariance

Let’s estimate the covariance of the two closing prices from the data. Just as the sample mean estimates \(\mu\), the sample covariance estimates \(\text{Cov}(X, Y)\):

Code
df.cov()
TSLA NVDA
TSLA 1757.018115 387.819338
NVDA 387.819338 115.323299

In the case of Tesla (\(X\)) and NVIDIA (\(Y\)) above, we find that

\[\text{Cov}(X,Y) \approx 388.\]

The diagonal entries are the variances of each stock’s price; the off-diagonal entry is the covariance.

Stock Correlation

How similar are these random variables? Let’s compute \(\rho(X,Y).\)

Code
df.corr()
TSLA NVDA
TSLA 1.000000 0.861555
NVDA 0.861555 1.000000

We observe that

\[\rho(X,Y) \approx 0.86.\]

There appears to be some similarity between the two stocks.

The Multivariate Gaussian

The most common multivariate distribution we will work with is the multivariate Gaussian – it is what a Gaussian looks like in two or more dimensions.

The multivariate normal distribution of a random vector \(\mathbf{X} = (X_1, \dots, X_k)^T\) is denoted

\[\mathbf{X} \sim \mathcal{N}(\mathbf{\mu}, \Sigma)\]

where \(\mathbf{\mu} = E[\mathbf{X}] = (E[X_1], \dots, E[X_k])^T\)

and \(\Sigma\) is the \(k \times k\) covariance matrix where \(\Sigma_{i,j} = \text{Cov}(X_i, X_j)\).

So the two parameters are the same as before – a mean and a “variance” – except now the mean is a vector and the variance is a matrix. The covariance matrix is what determines the shape of the distribution.

Multivariate Gaussian: Uncorrelated Components

We’ll consider two-component random vectors:

\[\mathbf{X} = \begin{bmatrix}X_1\\X_2\end{bmatrix}.\]

And our first example will be a simple one

\[ \mathbf{\mu} = \begin{bmatrix}1\\1\end{bmatrix}\;\;\;\;\Sigma = \begin{bmatrix}1 & 0\\0 & 1\end{bmatrix}.\]

We see that the variance (and standard deviation) of each component is 1.

However the covariances are zero – the components are uncorrelated.

We will take 600 samples from this distribution.

Multivariate Gaussian: Uncorrelated Components

Code
np.random.seed(4)
df1 = pd.DataFrame(multivariate_normal.rvs(mean = np.array([1, 1]),
                                           cov = np.eye(2),
                                           size = 600),
                                           columns = ['X1', 'X2'])
g = sns.JointGrid(data = df1, x = 'X1', y = 'X2', height = 5)
g.plot(sns.scatterplot, sns.kdeplot)
g.ax_joint.plot(1, 1, 'ro', markersize = 6)
g.ax_marg_x.plot(1, 0, 'ro')
g.ax_marg_y.plot(0, 1, 'ro')
plt.show()

The density contours are circles: no preferred direction.

Multivariate Gaussian: Positively Correlated Components

Next, we look at the case

\[\mathbf{\mu} = \begin{bmatrix} 1 \\ 1 \end{bmatrix} \qquad \Sigma = \begin{bmatrix} 1 & 0.8 \\ 0.8 & 1 \end{bmatrix}.\]

Notice that \(\text{Cov}(X_1, X_2) = 0.8\).

We say that the components are positively correlated.

Nonetheless, the marginals are still Gaussian.

Multivariate Gaussian: Positively Correlated Components

Code
np.random.seed(4)
df1 = pd.DataFrame(multivariate_normal.rvs(mean = np.array([1, 1]),
                                           cov = np.array([[1, 0.8],[0.8, 1]]),
                                           size = 600),
                                           columns = ['X1', 'X2'])
g = sns.JointGrid(data = df1, x = 'X1', y = 'X2', height = 5)
g.plot(sns.scatterplot, sns.kdeplot)
g.ax_joint.plot(1, 1, 'ro', markersize = 6)
g.ax_marg_x.plot(1, 0, 'ro')
g.ax_marg_y.plot(0, 1, 'ro')
plt.show()

The contours are ellipses tilted along the diagonal: the off-diagonal entry of \(\Sigma\) stretches the cloud.

Multivariate Gaussian: Negatively Correlated Components

Next, we look at the case

\[\mathbf{\mu} = \begin{bmatrix} 1 \\ 1 \end{bmatrix} \qquad \Sigma = \begin{bmatrix} 1 & -0.8 \\ -0.8 & 1 \end{bmatrix}.\]

Notice that \(\text{Cov}(X_1, X_2) = -0.8\). We say that the components are negatively correlated or anticorrelated.

Multivariate Gaussian: Negatively Correlated Components

Code
np.random.seed(4)
df1 = pd.DataFrame(multivariate_normal.rvs(mean = np.array([1, 1]),
                                           cov = np.array([[1, -0.8],[-0.8, 1]]),
                                           size = 600),
                                           columns = ['X1', 'X2'])
g = sns.JointGrid(data = df1, x = 'X1', y = 'X2', height = 5)
g.plot(sns.scatterplot, sns.kdeplot)
g.ax_joint.plot(1, 1, 'ro', markersize = 6)
g.ax_marg_x.plot(1, 0, 'ro')
g.ax_marg_y.plot(0, 1, 'ro')
plt.show()

Same ellipses, tilted the other way.

Fitting a Multivariate Gaussian to the Stock Data

The MLE story carries over: the maximum likelihood estimates of \(\mathbf{\mu}\) and \(\Sigma\) are the sample mean vector and the sample covariance matrix – exactly the df.mean() and df.cov() we already computed.

Code
g = sns.JointGrid(data = df, x = 'TSLA', y = 'NVDA', height = 5)
g.plot(sns.scatterplot, sns.kdeplot)
g.ax_joint.plot(df.mean()['TSLA'], df.mean()['NVDA'], 'ro', markersize = 6)
plt.show()

Recall that the correlation between these two stocks was about 0.86 – the cloud is stretched along the diagonal, like our \(\Sigma_{12} = 0.8\) example. (Whether a single Gaussian is a good fit here is questionable – and that is precisely the motivation for mixtures of Gaussians next lecture.)

Uncertainty: How Good Is a Sample Mean?

The Central Limit Theorem

We have been estimating means from data all lecture. How much should we trust such an estimate?

The key tool is the celebrated Central Limit Theorem. Informally,

The sum (or average) of a large number of independent observations from any distribution with finite variance tends to have a Gaussian distribution.

This is also why the Gaussian is such a good default model: measurement errors, daily temperatures, and many other quantities are themselves the accumulation of many small independent effects.

Note the “any distribution” – the individual observations do not need to be Gaussian.

The CLT in Action

Let’s check it on the horse-kick counts, which are certainly not Gaussian (they are 0, 1, 2, …). We repeatedly draw \(n\) corps-years at random and record the average count.

Code
rng = np.random.default_rng(0)
fig, axes = plt.subplots(1, 3, figsize=(13, 3.5))
for ax, n in zip(axes, [1, 5, 30]):
    means = np.array([rng.choice(counts, size=n, replace=True).mean() for _ in range(5000)])
    # the sample mean of n counts can only take values 0, 1/n, 2/n, ... so use bins centered on those
    bins = np.arange(means.min() - 0.5 / n, means.max() + 1 / n, 1 / n)
    ax.hist(means, bins=bins, density=True, alpha=0.6, edgecolor='black')
    xs = np.linspace(min(means), max(means), 200)
    ax.plot(xs, norm.pdf(xs, lam_hat, np.sqrt(counts.var() / n)), 'r-', lw=2.5)
    ax.set_title(f'Average of n = {n} counts')
    ax.set_xlabel('sample mean')
plt.suptitle('Distribution of the sample mean (histogram) vs. CLT Gaussian (red)', size=13)
plt.tight_layout()
plt.show()

By \(n = 30\) the sample mean is well described by a Gaussian – and notice how it narrows as \(n\) grows.

Confidence Intervals

Say you are concerned with some data that we take as coming from a random process.

You want to characterize it as accurately as possible. You measure it, yielding a single value.

How much does that value tell you? Can you rely on it as a description of the random process?

Let’s say you have a dataset and you compute its average value.

How certain are you that the average would be the same if you took another dataset from the same source (i.e., the same random process)?

Confidence Intervals

We think of the hypothetical data source as a random variable with a true mean \(\mu\).

We would like to find a range within which we are 90% sure that the true mean \(\mu\) lies.

In other words, we want the probability that the true mean lies in the interval to be 0.9.

This interval is then called the 90% confidence interval.

To be more precise: A confidence interval at level \(\gamma\) for a fixed but unknown parameter \(m\) is an interval \((A,B)\) such that

\[P(A < m < B) \geq \gamma.\]

Note that \(m\) is fixed — it is not random.

What is random is the interval \((A, B)\), which is constructed from the data, which (by assumption) are random.

Confidence Intervals

Each horizontal bar is the interval computed from one dataset. Most of them cover the true mean (vertical line); a few miss it.

Confidence Intervals for the Mean

Imagine we have a set of \(n\) samples of a random variable, \(x_1, x_2, ..., x_n\) Let’s assume that the random variable has mean \(\mu\) and variance \(\sigma^2\).

An estimate of \(\mu\) is the empirical average of the samples, \(\bar{x}\).

Now, the Central Limit Theorem tells us that the sum of a large number \(n\) of random variables, each with mean \(\mu\) and variance \(\sigma^2\), yields a Gaussian random variable with mean \(n\mu\) and variance \(n \sigma^2\).

So the distribution of the average of \(n\) samples is normal with mean \(\mu\) and variance \(\sigma^2 / n\). That is,

\[ \bar{x} \sim \mathcal{N}(\mu, \sigma^2 / n). \]

We usually assume that the number of samples should be 30 or more for the CLT to hold.

While the specific value 30 is a bit arbitrary, we will usually be using very large samples (datasets) in this course for which this assumption is valid.

The Standard Error

The standard deviation of the sample mean, \(\sigma / \sqrt{n}\), is called the standard error.

Notice that the standard error decreases as we increase the sample size, according to \(1/\sqrt{n}.\)

So it will turn out that using \(\bar{x}\), we can get increasingly “tight” estimates of \(\mu\) as we increase the number of samples \(n\) – exactly what we saw in the CLT simulation.

Now, remember that the true mean \(\mu\) is a constant, while the empirical mean \(\bar{x}\) is a random variable.

Let us assume for a moment that we know the true \(\mu\) and \(\sigma\), and that we accept that \(\bar{x}\) has a \(\mathcal{N}(\mu, \sigma^2/n)\) distribution.

Then it is true that

\[ P(\mu-k\sigma/\sqrt{n} < \bar{x} < \mu+k\sigma/\sqrt{n}) = P(-k < S < k) \]

where \(S\) is the standard Gaussian random variable (having distribution \(\mathcal{N}(0,1)\)).

Choosing \(k\)

We write \(z_{1-\alpha/2}\) to be the \(1-\alpha/2\) quantile of the unit normal. That is,

\[ P(-z_{1-\alpha/2} < S < z_{1-\alpha/2}) = 1-\alpha.\]

So to form a 90% probability interval for \(S\) (centered on zero) we choose \(k = z_{0.95}\).

Code
plt.figure(figsize=(6, 3.5))
x = np.linspace(norm.ppf(0.001), norm.ppf(0.999), 100)
x90 = np.linspace(norm.ppf(0.05), norm.ppf(0.95), 100)
plt.plot(x, norm.pdf(x),'b-')
plt.fill_between(x90, 0, norm.pdf(x90))
plt.title(r'90% region for Standard Gaussian', size = 14)
plt.xlabel('x', size = 14)
plt.ylabel(r'$p(x)$', size = 14)
plt.show()

From a Probability Interval to a Confidence Interval

Turning back to \(\bar{x}\), the 90% probability interval on \(\bar{x}\) would be:

\[ \mu-z_{0.95}\sigma/\sqrt{n} < \bar{x} < \mu+z_{0.95}\sigma/\sqrt{n}. \]

The last step: by a simple argument, we can show that the sample mean is in some fixed-size interval centered on the true mean, if and only if the true mean is also in a fixed-size interval (of the same size) centered on the sample mean.

This means that:

\[\begin{align*} 1-\alpha & = P(\mu-z_{1-\alpha/2}\sigma/\sqrt{n} < \bar{x} < \mu+z_{1-\alpha/2}\sigma/\sqrt{n}) \\ & = P(\bar{x}-z_{1-\alpha/2}\sigma/\sqrt{n} < \mu < \bar{x}+z_{1-\alpha/2}\sigma/\sqrt{n}). \end{align*}\]

This latter expression defines the \(1-\alpha\) confidence interval for the mean.

The Confidence Interval Formula

We are done, except for estimating \(\sigma\). We do this directly from the data: \(\hat{\sigma} = s\), where \(s\) is the sample standard deviation, that is,

\(s = \sqrt{\frac{1}{n-1} \sum (x_i - \bar{x})^2}\).

To summarize: by the argument presented here, a 100(1-\(\alpha\))% confidence interval for the population mean is given by

\[\bar{x} \pm z_{1-\alpha/2} \, \frac{s}{\sqrt{n}}. \]

As an example, a 95% confidence interval for the mean is the sample average plus or minus (about) two standard errors, since \(z_{0.975} \approx 1.96\).

A Confidence Interval on Real Data

What is the mean daily temperature in Boston in July? Let’s report it the way you should report a mean in a project: with a confidence interval.

Code
n = len(july)
xbar, s = july.mean(), july.std()
z = norm.ppf(0.975)                      # 1.96
se = s / np.sqrt(n)
print(f"n = {n},  sample mean = {xbar:.2f} °C,  sample std = {s:.2f} °C")
print(f"standard error = {se:.3f} °C")
print(f"95% CI for the mean July temperature: [{xbar - z*se:.2f}, {xbar + z*se:.2f}] °C")
n = 2821,  sample mean = 23.23 °C,  sample std = 3.17 °C
standard error = 0.060 °C
95% CI for the mean July temperature: [23.12, 23.35] °C

With thousands of days the interval is very tight. Compare a single year – 31 days – where the same formula gives a much wider interval:

Code
july_2020 = temps[(temps['MONTH'] == 7) & (temps['YEAR'] == 2020)]['TMEAN']
n1, xbar1, se1 = len(july_2020), july_2020.mean(), july_2020.std() / np.sqrt(len(july_2020))
print(f"July 2020 only: n = {n1}, mean = {xbar1:.2f} °C, 95% CI = [{xbar1 - z*se1:.2f}, {xbar1 + z*se1:.2f}] °C")
July 2020 only: n = 31, mean = 24.05 °C, 95% CI = [22.83, 25.28] °C

Reading a Confidence Interval Correctly

  • The 95% refers to the procedure: if we repeated the whole experiment many times, about 95% of the intervals we construct would contain the true mean. Any particular interval either does or does not.
  • The interval shrinks like \(1/\sqrt{n}\): to halve its width you need four times the data.
  • It quantifies uncertainty in the mean only, not the spread of individual values. Individual July days range over roughly \(\mu \pm 2\sigma \approx \pm 6\) °C, even though the mean is pinned down to a fraction of a degree.
  • The formula assumes independent samples. Consecutive days are not truly independent (hot days cluster), so the true interval is somewhat wider than reported. Be honest about that caveat when you use it.

Summary

Summary

Probability as a modeling tool – four things we did:

  1. Fit and check. Choose a distribution family whose shape matches the data (Gaussian for daily temperatures, Poisson for horse-kick counts), estimate its parameters, then check the fit by overlaying the density, comparing predicted and observed counts, and testing implied properties.
  2. Maximum Likelihood Estimation. The general recipe for step 2: write the likelihood of the data, take the log, maximize. For the Gaussian this gives the sample mean and variance; for the Poisson the sample mean count.
  3. Covariance, correlation, and the multivariate Gaussian. How two variables move together, and how the covariance matrix \(\Sigma\) shapes the ellipses of a 2-D Gaussian. The MLE of \((\mathbf{\mu}, \Sigma)\) is the sample mean and sample covariance.
  4. Uncertainty. The Central Limit Theorem says sample means are approximately Gaussian with standard error \(\sigma/\sqrt{n}\), which gives the confidence interval \(\bar{x} \pm z_{1-\alpha/2}\, s/\sqrt{n}\).

Next: Gaussian Mixture Models

  • The stock returns above did not look like one Gaussian. Many real datasets look like several overlapping Gaussians – one per cluster.
  • A Gaussian Mixture Model is exactly that: a weighted sum of multivariate Gaussians, each with its own \(\mathbf{\mu}_k\) and \(\Sigma_k\).
  • We will fit it by maximum likelihood – the same principle as today – but because we don’t know which cluster each point came from, the likelihood cannot be maximized in closed form. That is what the Expectation-Maximization algorithm is for.
  • Everything from today carries over: the multivariate Gaussian and its covariance ellipses, the log-likelihood, and the Gaussian MLE formulas.

Reference: for probability rules, Bayes’ theorem, and the full catalog of named distributions, see the Probability and Statistics Refresher.

Back to top

Footnotes

  1. By no conegut - MacTutor History of Mathematics: http://www-history.mcs.st-andrews.ac.uk/PictDisplay/Bortkiewicz.html, Public Domain, https://commons.wikimedia.org/w/index.php?curid=79219622↩︎

  2. Law of Small Numbers↩︎