Multivariate Statistics

Coding, simulations, scatterplots, regressions, conditional means

Constructing a dataset: 3×9d8 and secondary colors

Imagine three sets of colored d8: - 9 red d8 - 9 blue d8 - 9 yellow d8

For each observation:

  1. Roll the 9 red d8 and record their sum as R9d8.
  2. Roll the 9 blue d8 and record their sum as B9d8.
  3. Roll the 9 yellow d8 and record their sum as Y9d8.

The variables R9d8, B9d8, and Y9d8 are our primary variables.

We then construct two secondary variables by combining primary variables.

  • purple_9d8 = R9d8 + B9d8
  • orange_9d8 = R9d8 + Y9d8
import random
import pandas as pd
import matplotlib.pyplot as plt
random.seed(989898)

def roll_9d8():

    total = 0

    for i in range(9):
        total += random.randint(1, 8)

    return total

n_observations = 150

R9d8 = []
B9d8 = []
Y9d8 = []

for i in range(n_observations):

    R9d8.append(roll_9d8())
    B9d8.append(roll_9d8())
    Y9d8.append(roll_9d8())

df_RBY_9d8 = pd.DataFrame({
    "R9d8": R9d8,
    "B9d8": B9d8,
    "Y9d8": Y9d8
})

df_RBY_9d8["purple_9d8"] = (
    df_RBY_9d8["R9d8"]
    + df_RBY_9d8["B9d8"]
)

df_RBY_9d8["orange_9d8"] = (
    df_RBY_9d8["R9d8"]
    + df_RBY_9d8["Y9d8"]
)

print(df_RBY_9d8.head())
   R9d8  B9d8  Y9d8  purple_9d8  orange_9d8
0    39    40    37          79          76
1    33    38    36          71          69
2    37    35    44          72          81
3    31    40    46          71          77
4    37    45    51          82          88

The dataframe contains one observation per row and one variable per column.

Marginal distributions

The next figure displays the distribution of each variable separately.

fig, axes = plt.subplots(
    5,
    1,
    figsize=(6,30)
)

variables = [
    "R9d8",
    "B9d8",
    "Y9d8",
    "purple_9d8",
    "orange_9d8"
]

colornames = [
    "red",
    "blue",
    "yellow",
    "purple",
    "orange"
]

for ax, variable, colorname in zip(
    axes,
    variables,
    colornames
):

    counts = (
        df_RBY_9d8[variable]
        .value_counts()
        .sort_index()
    )

    ax.bar(
        counts.index,
        counts.values,
        color=colorname
    )

    ax.set_title(variable)
    ax.set_ylabel("Count")

plt.tight_layout()

plt.show()
plt.close()

print(df_RBY_9d8.describe())

             R9d8        B9d8        Y9d8  purple_9d8  orange_9d8
count  150.000000  150.000000  150.000000  150.000000  150.000000
mean    40.693333   40.633333   40.720000   81.326667   81.413333
std      6.590563    6.600200    6.830763    8.848840    9.293995
min     27.000000   24.000000   21.000000   55.000000   56.000000
25%     36.000000   36.000000   36.000000   75.250000   75.000000
50%     40.000000   40.000000   41.000000   82.000000   81.500000
75%     45.000000   45.000000   46.000000   87.000000   88.000000
max     55.000000   63.000000   57.000000  109.000000  109.000000

Pairwise relationships

The next figure displays scatterplots for three variable pairs:

  • R9d8 and B9d8
  • R9d8 and orange_9d8
  • purple_9d8 and orange_9d8
fig, axes = plt.subplots(
    3,
    1,
    figsize=(6,18)
)

axes[0].scatter(
    df_RBY_9d8["R9d8"],
    df_RBY_9d8["B9d8"],
    s=10
)

axes[0].set_xlabel("R9d8")
axes[0].set_ylabel("B9d8")

axes[1].scatter(
    df_RBY_9d8["R9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10
)

axes[1].set_xlabel("R9d8")
axes[1].set_ylabel("orange_9d8")

axes[2].scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10
)

axes[2].set_xlabel("purple_9d8")
axes[2].set_ylabel("orange_9d8")

plt.tight_layout()

plt.show()
plt.close()

The prediction metaphor

When running regressions, our ongoing metaphor will be that we try to predict orange_9d8. This does not refer to literal prediction as in “predict the future”: we have the observations of orange_9d8 already, so we do not have to “predict” anything. We also know exactly how orange_9d8 was constructed, and if both R9d8 and Y9d8 are available, “predicting” orange_9d8 from them is trivial.

One interpretation of “predict” is out-of-sample prediction: we try to guess what the value of the hypothetical next observation would be.

The other interpretation of “predict” is metaphorical: “predict” stands for “document relationships between variables and determine how observing one variable is informative about another”. Outside of simulation settings, researchers observe a dataset but do not observe the data-generating process. We place ourselves in the position of such a researcher, and represent ignorance of the data-generating process by restricting access to some variables and working with subsets of the full dataframe.

Predicting orange_9d8 from nothing

Suppose the only available variable is:

  • orange_9d8

All other variables are hidden.

The dataframe below contains exactly that variable.

df_orange_only = (
    df_RBY_9d8[
        ["orange_9d8"]
    ]
)

print(df_orange_only.head())
   orange_9d8
0          76
1          69
2          81
3          77
4          88

The histogram below displays the distribution of orange_9d8. Without access to any other variables, a natural prediction is the sample average of orange_9d8. The vertical line indicates the sample average.

orange_9d8_mean = (
    df_orange_only["orange_9d8"]
    .mean()
)


orange_counts = (
    df_orange_only["orange_9d8"]
    .value_counts()
    .sort_index()
)

plt.bar(
    orange_counts.index,
    orange_counts.values,
    color="orange"
)

plt.axvline(
    orange_9d8_mean,
    color="black"
)

plt.xlabel("orange_9d8")
plt.ylabel("Count")

plt.show()
plt.close()


print("Sample mean of orange_9d8:", orange_9d8_mean)

Sample mean of orange_9d8: 81.41333333333333

Predicting orange_9d8 from B9d8

Suppose the available variables are:

  • B9d8
  • orange_9d8

The remaining variables are hidden.

The dataframe below contains exactly those variables.

df_B = (
    df_RBY_9d8[
        [
            "B9d8",
            "orange_9d8"
        ]
    ]
)

print(df_B.head())
   B9d8  orange_9d8
0    40          76
1    38          69
2    35          81
3    40          77
4    45          88

The scatterplot below displays all observed pairs of values.

plt.figure(figsize=(6,6))

plt.scatter(
    df_B["B9d8"],
    df_B["orange_9d8"],
    s=10
)

plt.xlabel("B9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()

We now compute the line of best fit relating orange_9d8 to B9d8. For that we load the statsmodels.api library

import statsmodels.formula.api as smf

results_B = smf.ols(
    "orange_9d8 ~ B9d8",
    data=df_B
).fit()

print(results_B.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:             orange_9d8   R-squared:                       0.004
Model:                            OLS   Adj. R-squared:                 -0.003
Method:                 Least Squares   F-statistic:                    0.5542
Date:                Wed, 26 Aug 2026   Prob (F-statistic):              0.458
Time:                        21:24:53   Log-Likelihood:                -546.46
No. Observations:                 150   AIC:                             1097.
Df Residuals:                     148   BIC:                             1103.
Df Model:                           1                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     84.9081      4.756     17.854      0.000      75.511      94.306
B9d8          -0.0860      0.116     -0.744      0.458      -0.314       0.142
==============================================================================
Omnibus:                        0.090   Durbin-Watson:                   2.045
Prob(Omnibus):                  0.956   Jarque-Bera (JB):                0.165
Skew:                           0.057   Prob(JB):                        0.921
Kurtosis:                       2.883   Cond. No.                         258.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

The summary table reports:

  • coefficient estimates
  • standard errors
  • t statistics
  • confidence intervals
  • R²
  • additional diagnostics

The next figure displays the scatterplot together with the fitted line.

intercept_B = (
    results_B.params["Intercept"]
)

slope_B = (
    results_B.params["B9d8"]
)

x_line_B = list(
    range(
        int(df_B["B9d8"].min()),
        int(df_B["B9d8"].max()) + 1
    )
)

y_line_B = [
    intercept_B + slope_B * x
    for x in x_line_B
]

plt.figure(figsize=(6,6))

plt.scatter(
    df_B["B9d8"],
    df_B["orange_9d8"],
    s=10
)

plt.plot(
    x_line_B,
    y_line_B,
    color="red"
)

plt.xlabel("B9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()

Predicting orange_9d8 from R9d8

Suppose the available variables are:

  • R9d8
  • orange_9d8

The remaining variables are hidden.

The dataframe below contains exactly those variables.

df_R = (
    df_RBY_9d8[
        [
            "R9d8",
            "orange_9d8"
        ]
    ]
)

print(df_R.head())
   R9d8  orange_9d8
0    39          76
1    33          69
2    37          81
3    31          77
4    37          88

The scatterplot below displays all observed pairs of values.

plt.figure(figsize=(6,6))

plt.scatter(
    df_R["R9d8"],
    df_R["orange_9d8"],
    s=10
)

plt.xlabel("R9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()

We now compute the line of best fit relating orange_9d8 to R9d8.

results_R = smf.ols(
    "orange_9d8 ~ R9d8",
    data=df_R
).fit()

print(results_R.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:             orange_9d8   R-squared:                       0.461
Model:                            OLS   Adj. R-squared:                  0.457
Method:                 Least Squares   F-statistic:                     126.5
Date:                Wed, 26 Aug 2026   Prob (F-statistic):           1.36e-21
Time:                        21:24:53   Log-Likelihood:                -500.43
No. Observations:                 150   AIC:                             1005.
Df Residuals:                     148   BIC:                             1011.
Df Model:                           1                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     42.4609      3.509     12.101      0.000      35.527      49.395
R9d8           0.9572      0.085     11.245      0.000       0.789       1.125
==============================================================================
Omnibus:                        2.297   Durbin-Watson:                   1.999
Prob(Omnibus):                  0.317   Jarque-Bera (JB):                2.286
Skew:                          -0.294   Prob(JB):                        0.319
Kurtosis:                       2.862   Cond. No.                         259.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

The next figure displays the scatterplot together with the fitted line.

intercept_R = (
    results_R.params["Intercept"]
)

slope_R = (
    results_R.params["R9d8"]
)

x_line_R = list(
    range(
        int(df_R["R9d8"].min()),
        int(df_R["R9d8"].max()) + 1
    )
)

y_line_R = [
    intercept_R + slope_R * x
    for x in x_line_R
]

plt.figure(figsize=(6,6))

plt.scatter(
    df_R["R9d8"],
    df_R["orange_9d8"],
    s=10
)

plt.plot(
    x_line_R,
    y_line_R,
    color="red"
)

plt.xlabel("R9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()

Predicting orange_9d8 from purple_9d8

Suppose the available variables are:

  • purple_9d8
  • orange_9d8

The remaining variables are hidden.

The dataframe below contains exactly those variables.

df_purple = (
    df_RBY_9d8[
        [
            "purple_9d8",
            "orange_9d8"
        ]
    ]
)

print(df_purple.head())
   purple_9d8  orange_9d8
0          79          76
1          71          69
2          72          81
3          71          77
4          82          88

The scatterplot below displays all observed pairs of values.

plt.figure(figsize=(6,6))

plt.scatter(
    df_purple["purple_9d8"],
    df_purple["orange_9d8"],
    s=10
)

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()

We now compute the line of best fit relating orange_9d8 to purple_9d8.

results_purple = smf.ols(
    "orange_9d8 ~ purple_9d8",
    data=df_purple
).fit()

print(results_purple.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:             orange_9d8   R-squared:                       0.212
Model:                            OLS   Adj. R-squared:                  0.206
Method:                 Least Squares   F-statistic:                     39.72
Date:                Wed, 26 Aug 2026   Prob (F-statistic):           3.18e-09
Time:                        21:24:54   Log-Likelihood:                -528.91
No. Observations:                 150   AIC:                             1062.
Df Residuals:                     148   BIC:                             1068.
Df Model:                           1                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     42.1215      6.271      6.717      0.000      29.729      54.514
purple_9d8     0.4831      0.077      6.302      0.000       0.332       0.635
==============================================================================
Omnibus:                        0.715   Durbin-Watson:                   2.053
Prob(Omnibus):                  0.699   Jarque-Bera (JB):                0.407
Skew:                          -0.099   Prob(JB):                        0.816
Kurtosis:                       3.162   Cond. No.                         759.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

The next figure displays the scatterplot together with the fitted line.

intercept_purple = (
    results_purple.params["Intercept"]
)

slope_purple = (
    results_purple.params["purple_9d8"]
)

x_line_purple = list(
    range(
        int(df_purple["purple_9d8"].min()),
        int(df_purple["purple_9d8"].max()) + 1
    )
)

y_line_purple = [
    intercept_purple + slope_purple * x
    for x in x_line_purple
]

plt.figure(figsize=(6,6))

plt.scatter(
    df_purple["purple_9d8"],
    df_purple["orange_9d8"],
    s=10
)

plt.plot(
    x_line_purple,
    y_line_purple,
    color="red"
)

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()

The regression summary reports the estimated relationship between purple_9d8 and orange_9d8.

The regression of orange_9d8 on purple_9d8 will serve as the benchmark regression in the next sections.

Off-ramp: The intercept

The regression summary contains a coefficient labelled const.

This coefficient is the intercept of the regression line.

The intercept is the predicted value of orange_9d8 when the predictor equals zero.

To see where this value appears on the graph, we redraw the scatterplot and force the horizontal axis to include zero.

plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10
)

# Solid part: observed support
plt.plot(
    x_line_purple,
    y_line_purple,
    color="red"
)

# Dashed extrapolation to the intercept
x_line_intercept = [
    0,
    x_line_purple[0]
]

y_line_intercept = [
    intercept_purple,
    intercept_purple + slope_purple * x_line_purple[0]
]

plt.plot(
    x_line_intercept,
    y_line_intercept,
    color="red",
    linestyle="--"
)

plt.scatter(
    [0],
    [intercept_purple],
    color="black",
    s=80,
    zorder=10
)

plt.xlim(left=-10)

plt.ylim(
    min(
        intercept_purple - 10,
        df_RBY_9d8["orange_9d8"].min()
    ),
    df_RBY_9d8["orange_9d8"].max() + 5
)

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()

The black point is located at: (0, Intercept). It lies on the fitted regression line, albeit in a region of the scatterplot where there are no observations. The coefficient labelled Intercept therefore determines where the regression line crosses the vertical axis.

Off-ramp: Centering the predictor

The meaning of the intercept depends on how the predictor is measured.

We now create a centered version of purple_9d8 by subtracting its sample mean.

purple_9d8_mean = (
    df_RBY_9d8["purple_9d8"]
    .mean()
)

print(
    "Sample mean of purple_9d8:",
    purple_9d8_mean
)

df_RBY_9d8["purple_9d8_centered"] = (
    df_RBY_9d8["purple_9d8"]
    -
    purple_9d8_mean
)

print(
    df_RBY_9d8[
        [
            "purple_9d8",
            "purple_9d8_centered"
        ]
    ].head()
)
Sample mean of purple_9d8: 81.32666666666667
   purple_9d8  purple_9d8_centered
0          79            -2.326667
1          71           -10.326667
2          72            -9.326667
3          71           -10.326667
4          82             0.673333

The centered variable measures distance from the sample mean.

A value of:

purple_9d8_centered = 0

corresponds to an observation whose value of purple_9d8 equals the sample mean.

We now rerun the regression using the centered predictor.

results_centered = smf.ols(
    "orange_9d8 ~ purple_9d8_centered",
    data=df_RBY_9d8
).fit()

The next figure displays the data in the original purple_9d8 scale together with the fitted line from the centered regression.

centercept = (
    results_centered.params["Intercept"]
)

slope_centered = (
    results_centered.params[
        "purple_9d8_centered"
    ]
)

x_line_centered = list(
    range(
        int(
            df_RBY_9d8[
                "purple_9d8"
            ].min()
        ),
        int(
            df_RBY_9d8[
                "purple_9d8"
            ].max()
        ) + 1
    )
)

y_line_centered = [
    centercept
    +
    slope_centered
    *
    (
        x
        -
        purple_9d8_mean
    )
    for x in x_line_centered
]

plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10
)

plt.plot(
    x_line_centered,
    y_line_centered,
    color="red"
)

plt.scatter(
    [purple_9d8_mean],
    [centercept],
    color="black",
    s=80,
    zorder=10
)

plt.axvline(
    purple_9d8_mean,
    color="black",
    linestyle="--"
)

plt.axhline(
    centercept,
    color="black",
    linestyle="--"
)

plt.xlabel(
    "purple_9d8"
)

plt.ylabel(
    "orange_9d8"
)

plt.grid(alpha=0.3)

plt.show()
plt.close()

The black point has coordinates:

(sample mean of purple_9d8, Intercept)

We now print the regression summary.

print(
    results_centered.summary()
)
                            OLS Regression Results                            
==============================================================================
Dep. Variable:             orange_9d8   R-squared:                       0.212
Model:                            OLS   Adj. R-squared:                  0.206
Method:                 Least Squares   F-statistic:                     39.72
Date:                Wed, 26 Aug 2026   Prob (F-statistic):           3.18e-09
Time:                        21:24:54   Log-Likelihood:                -528.91
No. Observations:                 150   AIC:                             1062.
Df Residuals:                     148   BIC:                             1068.
Df Model:                           1                                         
Covariance Type:            nonrobust                                         
=======================================================================================
                          coef    std err          t      P>|t|      [0.025      0.975]
---------------------------------------------------------------------------------------
Intercept              81.4133      0.676    120.421      0.000      80.077      82.749
purple_9d8_centered     0.4831      0.077      6.302      0.000       0.332       0.635
==============================================================================
Omnibus:                        0.715   Durbin-Watson:                   2.053
Prob(Omnibus):                  0.699   Jarque-Bera (JB):                0.407
Skew:                          -0.099   Prob(JB):                        0.816
Kurtosis:                       3.162   Cond. No.                         8.82
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

The coefficient labelled Intercept is the fitted value when:

purple_9d8_centered = 0

Since purple_9d8_centered was constructed by subtracting the sample mean of purple_9d8, the value:

purple_9d8_centered = 0

corresponds to:

purple_9d8 = sample mean of purple_9d8

For this reason, the intercept in the centered regression is sometimes informally called a “centercept”.

Fitted values, Residuals, Ordinary Least Squares

The fitted values are the values predicted by the regression line.

They are computed directly from the estimated intercept and slope.

df_RBY_9d8["orange_9d8_predicted"] = (
    results_purple.fittedvalues
)

print(
    df_RBY_9d8[
        [
            "purple_9d8",
            "orange_9d8",
            "orange_9d8_predicted"
        ]
    ].head()
)
   purple_9d8  orange_9d8  orange_9d8_predicted
0          79          76             80.289236
1          71          69             76.424147
2          72          81             76.907283
3          71          77             76.424147
4          82          88             81.738645

The table displays:

  • the predictor purple_9d8
  • the observed value orange_9d8
  • the fitted value orange_9d8_predicted

The fitted values lie on the regression line.

For each observation, the residual is defined as:

residual = observed value - fitted value

df_RBY_9d8["orange_9d8_residual"] = (
    df_RBY_9d8["orange_9d8"]
    -
    df_RBY_9d8["orange_9d8_predicted"]
)

print(
    df_RBY_9d8[
        [
            "orange_9d8",
            "orange_9d8_predicted",
            "orange_9d8_residual"
        ]
    ].head()
)
   orange_9d8  orange_9d8_predicted  orange_9d8_residual
0          76             80.289236            -4.289236
1          69             76.424147            -7.424147
2          81             76.907283             4.092717
3          77             76.424147             0.575853
4          88             81.738645             6.261355

The table displays:

  • the observed value
  • the fitted value
  • the residual

Every observation now has both a prediction and an associated prediction error. The table displays:

  • the observed value
  • the fitted value
  • the residual

Every observation now has both a prediction and an associated prediction error.

The residual for an observation is the vertical distance between the observed point and the fitted line.

The graph below displays a subset of residuals.

plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10
)

plt.plot(
    x_line_purple,
    y_line_purple,
    color="red"
)

for i in range(20):

    plt.plot(
        [
            df_RBY_9d8["purple_9d8"].iloc[i],
            df_RBY_9d8["purple_9d8"].iloc[i]
        ],
        [
            df_RBY_9d8["orange_9d8"].iloc[i],
            df_RBY_9d8["orange_9d8_predicted"].iloc[i]
        ],
        color="gray"
    )

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8")

plt.show()
plt.close()

Each gray segment corresponds to one residual.

The regression line is computed using Ordinary Least Squares (OLS).

OLS chooses the intercept and slope that minimize the sum of squared residuals.

We can compute that quantity directly from the residuals.

SSR = (
    df_RBY_9d8["orange_9d8_residual"]**2
).sum()

print(SSR)
10147.057044688465

The fitted regression line is the line with the smallest possible value of the sum of squared residuals among all straight lines.

Residuals versus fitted values

The residuals can be plotted against the fitted values produced by the regression.

The horizontal line corresponds to a residual equal to zero.

plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["orange_9d8_predicted"],
    df_RBY_9d8["orange_9d8_residual"],
    s=10
)

plt.axhline(
    0,
    color="red"
)

plt.xlabel("orange_9d8_predicted")
plt.ylabel("orange_9d8_residual")

plt.grid(alpha=0.3)

plt.show()
plt.close()

Each point corresponds to one observation.

The horizontal position records the fitted value.

The vertical position records the corresponding residual.

Residuals versus purple_9d8

The residuals can also be plotted against the predictor used in the regression.

plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8_residual"],
    s=10
)

plt.axhline(
    0,
    color="red"
)

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8_residual")

plt.grid(alpha=0.3)

plt.show()
plt.close()

Each point corresponds to one observation.

The horizontal position records the value of purple_9d8.

The vertical position records the residual from the benchmark regression.

Off-ramp: Conditional means

The regression line is one way of summarizing the relationship between two variables.

Another approach is to compute conditional means.

For each observed value of purple_9d8, we compute the average value of orange_9d8.

conditional_means = (
    df_RBY_9d8
    .groupby("purple_9d8")
    ["orange_9d8"]
    .mean()
    .reset_index()
)

print(
    conditional_means.head()
)
   purple_9d8  orange_9d8
0          55        82.0
1          61        63.0
2          62        72.0
3          66        77.0
4          68        76.0

The table contains:

  • a value of purple_9d8
  • the corresponding average value of orange_9d8

Each row summarizes a subset of observations from the dataset.

The conditional means can be added to the scatterplot.

plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10,
    alpha=0.3
)

plt.plot(
    conditional_means["purple_9d8"],
    conditional_means["orange_9d8"],
    color="red",
    linewidth=2
)

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()

The red curve joins the conditional means.

Conditional means and regression

The next figure displays:

  • observations
  • conditional means
  • regression line
plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10,
    alpha=0.3
)

plt.plot(
    conditional_means["purple_9d8"],
    conditional_means["orange_9d8"],
    color="red",
    linewidth=2,
    label="Conditional means"
)

plt.plot(
    x_line_purple,
    y_line_purple,
    color="black",
    linewidth=2,
    label="Regression line"
)

plt.legend()

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()

The conditional means and the regression line are constructed from the same pair of variables:

  • purple_9d8
  • orange_9d8

The conditional means use one average for each observed value of purple_9d8.

The regression uses a single straight line.

Multiple predictors: setup

So far, we have estimated several regressions of orange_9d8 on a single variable. We now turn to multiple regression, with more than one variable. To make comparisons easier, we will progressively build a regression results table, with only key results, for ease of comparison. However, we should not skip printing full regression summaries, so we will progressively add them to a large appendix document, to be printed as an appendix.

Initializing the regression results table and appendix

The rows of the table correspond to quantities reported by regression summaries. The columns correspond to different regression specifications. A helper function adds the results from a regression to a new column.

regression_results_table = pd.DataFrame(
    index=[
        "Intercept_coef",
        "Intercept_P>|t|",
        "",          #### Blank rows make the table more readable
        "R9d8_coef",
        "R9d8_P>|t|",
       "",
        "B9d8_coef",
        "B9d8_P>|t|",
        "",
        "purple_9d8_coef",
        "purple_9d8_P>|t|",
        "",
        "R-squared",
        "Adj. R-squared",
        "No. Observations"
    ]
)

regression_summaries = {}

def add_regression_to_table(
    table,
    column_name,
    results
):

    column = pd.Series(index=table.index, dtype=float)

    for variable in results.params.index:
        column[f"{variable}_coef"] = results.params[variable]
        column[f"{variable}_P>|t|"] = results.pvalues[variable]

    column["R-squared"] = results.rsquared
    column["Adj. R-squared"] = results.rsquared_adj
    column["No. Observations"] = results.nobs

    table[column_name] = column
    
    regression_summaries[column_name] = (
        results.summary()
    )

We begin by adding existing regression results to the table.

add_regression_to_table(
    regression_results_table,
    "B",
    results_B
)

add_regression_to_table(
    regression_results_table,
    "R",
    results_R
)

add_regression_to_table(
    regression_results_table,
    "purple",
    results_purple
)

print(regression_results_table.round(3).fillna(""))
                       B       R  purple
Intercept_coef    84.908  42.461  42.121
Intercept_P>|t|      0.0     0.0     0.0
                                        
R9d8_coef                  0.957        
R9d8_P>|t|                   0.0        
                                        
B9d8_coef         -0.086                
B9d8_P>|t|         0.458                
                                        
purple_9d8_coef                    0.483
purple_9d8_P>|t|                     0.0
                                        
R-squared          0.004   0.461   0.212
Adj. R-squared    -0.003   0.457   0.206
No. Observations   150.0   150.0   150.0

Multiple predictors

We now add B9d8 to the regression that previously used only R9d8.

results_RB = smf.ols(
    "orange_9d8 ~ R9d8 + B9d8",
    data=df_RBY_9d8
).fit()

add_regression_to_table(
    regression_results_table,
    "RB",
    results_RB
)

print(regression_results_table.round(3).fillna(""))
                       B       R  purple     RB
Intercept_coef    84.908  42.461  42.121  42.03
Intercept_P>|t|      0.0     0.0     0.0    0.0
                                               
R9d8_coef                  0.957          0.958
R9d8_P>|t|                   0.0            0.0
                                               
B9d8_coef         -0.086                   0.01
B9d8_P>|t|         0.458                  0.911
                                               
purple_9d8_coef                    0.483       
purple_9d8_P>|t|                     0.0       
                                               
R-squared          0.004   0.461   0.212  0.461
Adj. R-squared    -0.003   0.457   0.206  0.453
No. Observations   150.0   150.0   150.0  150.0
results_purple_R = smf.ols(
    "orange_9d8 ~ purple_9d8 + R9d8",
    data=df_RBY_9d8
).fit()

add_regression_to_table(
    regression_results_table,
    "purple R",
    results_purple_R
)

print(regression_results_table.round(3).fillna(""))
                       B       R  purple     RB purple R
Intercept_coef    84.908  42.461  42.121  42.03    42.03
Intercept_P>|t|      0.0     0.0     0.0    0.0      0.0
                                                        
R9d8_coef                  0.957          0.958    0.949
R9d8_P>|t|                   0.0            0.0      0.0
                                                        
B9d8_coef         -0.086                   0.01         
B9d8_P>|t|         0.458                  0.911         
                                                        
purple_9d8_coef                    0.483            0.01
purple_9d8_P>|t|                     0.0           0.911
                                                        
R-squared          0.004   0.461   0.212  0.461    0.461
Adj. R-squared    -0.003   0.457   0.206  0.453    0.453
No. Observations   150.0   150.0   150.0  150.0    150.0
results_purple_B = smf.ols(
    "orange_9d8 ~ purple_9d8 + B9d8",
    data=df_RBY_9d8
).fit()


add_regression_to_table(
    regression_results_table,
    "purple B",
    results_purple_B
)

print(regression_results_table.round(3).fillna(""))
                       B       R  purple     RB purple R purple B
Intercept_coef    84.908  42.461  42.121  42.03    42.03    42.03
Intercept_P>|t|      0.0     0.0     0.0    0.0      0.0      0.0
                                                                 
R9d8_coef                  0.957          0.958    0.949         
R9d8_P>|t|                   0.0            0.0      0.0         
                                                                 
B9d8_coef         -0.086                   0.01            -0.949
B9d8_P>|t|         0.458                  0.911               0.0
                                                                 
purple_9d8_coef                    0.483            0.01    0.958
purple_9d8_P>|t|                     0.0           0.911      0.0
                                                                 
R-squared          0.004   0.461   0.212  0.461    0.461    0.461
Adj. R-squared    -0.003   0.457   0.206  0.453    0.453    0.453
No. Observations   150.0   150.0   150.0  150.0    150.0    150.0

The table provides a compact summary of the regressions estimated so far.

The complete regression summaries are gathered as an appendix. There is sometimes important information contained in them (see later section on multicollinearity).

for column_name, regression_summary in regression_summaries.items():
    print(f"Regression summary for column {column_name}:")
    print(regression_summary)
    print(f"\n\n")
Regression summary for column B:
                            OLS Regression Results                            
==============================================================================
Dep. Variable:             orange_9d8   R-squared:                       0.004
Model:                            OLS   Adj. R-squared:                 -0.003
Method:                 Least Squares   F-statistic:                    0.5542
Date:                Wed, 26 Aug 2026   Prob (F-statistic):              0.458
Time:                        21:24:55   Log-Likelihood:                -546.46
No. Observations:                 150   AIC:                             1097.
Df Residuals:                     148   BIC:                             1103.
Df Model:                           1                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     84.9081      4.756     17.854      0.000      75.511      94.306
B9d8          -0.0860      0.116     -0.744      0.458      -0.314       0.142
==============================================================================
Omnibus:                        0.090   Durbin-Watson:                   2.045
Prob(Omnibus):                  0.956   Jarque-Bera (JB):                0.165
Skew:                           0.057   Prob(JB):                        0.921
Kurtosis:                       2.883   Cond. No.                         258.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.



Regression summary for column R:
                            OLS Regression Results                            
==============================================================================
Dep. Variable:             orange_9d8   R-squared:                       0.461
Model:                            OLS   Adj. R-squared:                  0.457
Method:                 Least Squares   F-statistic:                     126.5
Date:                Wed, 26 Aug 2026   Prob (F-statistic):           1.36e-21
Time:                        21:24:55   Log-Likelihood:                -500.43
No. Observations:                 150   AIC:                             1005.
Df Residuals:                     148   BIC:                             1011.
Df Model:                           1                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     42.4609      3.509     12.101      0.000      35.527      49.395
R9d8           0.9572      0.085     11.245      0.000       0.789       1.125
==============================================================================
Omnibus:                        2.297   Durbin-Watson:                   1.999
Prob(Omnibus):                  0.317   Jarque-Bera (JB):                2.286
Skew:                          -0.294   Prob(JB):                        0.319
Kurtosis:                       2.862   Cond. No.                         259.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.



Regression summary for column purple:
                            OLS Regression Results                            
==============================================================================
Dep. Variable:             orange_9d8   R-squared:                       0.212
Model:                            OLS   Adj. R-squared:                  0.206
Method:                 Least Squares   F-statistic:                     39.72
Date:                Wed, 26 Aug 2026   Prob (F-statistic):           3.18e-09
Time:                        21:24:55   Log-Likelihood:                -528.91
No. Observations:                 150   AIC:                             1062.
Df Residuals:                     148   BIC:                             1068.
Df Model:                           1                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     42.1215      6.271      6.717      0.000      29.729      54.514
purple_9d8     0.4831      0.077      6.302      0.000       0.332       0.635
==============================================================================
Omnibus:                        0.715   Durbin-Watson:                   2.053
Prob(Omnibus):                  0.699   Jarque-Bera (JB):                0.407
Skew:                          -0.099   Prob(JB):                        0.816
Kurtosis:                       3.162   Cond. No.                         759.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.



Regression summary for column RB:
                            OLS Regression Results                            
==============================================================================
Dep. Variable:             orange_9d8   R-squared:                       0.461
Model:                            OLS   Adj. R-squared:                  0.453
Method:                 Least Squares   F-statistic:                     62.81
Date:                Wed, 26 Aug 2026   Prob (F-statistic):           1.92e-20
Time:                        21:24:55   Log-Likelihood:                -500.42
No. Observations:                 150   AIC:                             1007.
Df Residuals:                     147   BIC:                             1016.
Df Model:                           2                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     42.0304      5.204      8.077      0.000      31.747      52.314
R9d8           0.9582      0.086     11.163      0.000       0.789       1.128
B9d8           0.0096      0.086      0.112      0.911      -0.160       0.179
==============================================================================
Omnibus:                        2.322   Durbin-Watson:                   2.001
Prob(Omnibus):                  0.313   Jarque-Bera (JB):                2.312
Skew:                          -0.296   Prob(JB):                        0.315
Kurtosis:                       2.861   Cond. No.                         537.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.



Regression summary for column purple R:
                            OLS Regression Results                            
==============================================================================
Dep. Variable:             orange_9d8   R-squared:                       0.461
Model:                            OLS   Adj. R-squared:                  0.453
Method:                 Least Squares   F-statistic:                     62.81
Date:                Wed, 26 Aug 2026   Prob (F-statistic):           1.92e-20
Time:                        21:24:55   Log-Likelihood:                -500.42
No. Observations:                 150   AIC:                             1007.
Df Residuals:                     147   BIC:                             1016.
Df Model:                           2                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     42.0304      5.204      8.077      0.000      31.747      52.314
purple_9d8     0.0096      0.086      0.112      0.911      -0.160       0.179
R9d8           0.9486      0.115      8.242      0.000       0.721       1.176
==============================================================================
Omnibus:                        2.322   Durbin-Watson:                   2.001
Prob(Omnibus):                  0.313   Jarque-Bera (JB):                2.312
Skew:                          -0.296   Prob(JB):                        0.315
Kurtosis:                       2.861   Cond. No.                         849.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.



Regression summary for column purple B:
                            OLS Regression Results                            
==============================================================================
Dep. Variable:             orange_9d8   R-squared:                       0.461
Model:                            OLS   Adj. R-squared:                  0.453
Method:                 Least Squares   F-statistic:                     62.81
Date:                Wed, 26 Aug 2026   Prob (F-statistic):           1.92e-20
Time:                        21:24:55   Log-Likelihood:                -500.42
No. Observations:                 150   AIC:                             1007.
Df Residuals:                     147   BIC:                             1016.
Df Model:                           2                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     42.0304      5.204      8.077      0.000      31.747      52.314
purple_9d8     0.9582      0.086     11.163      0.000       0.789       1.128
B9d8          -0.9486      0.115     -8.242      0.000      -1.176      -0.721
==============================================================================
Omnibus:                        2.322   Durbin-Watson:                   2.001
Prob(Omnibus):                  0.313   Jarque-Bera (JB):                2.312
Skew:                          -0.296   Prob(JB):                        0.315
Kurtosis:                       2.861   Cond. No.                         849.
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.


Complement: Correlation and covariance

Correlation and covariance provide numerical summaries of pairwise relationships between variables. They can be computed automatically by pandas dataframe:

covariance_matrix = (
    df_RBY_9d8[
        [
            "R9d8",
            "B9d8",
            "purple_9d8",
            "orange_9d8"
        ]
    ]
    .cov()
)

print(covariance_matrix)
                 R9d8       B9d8  purple_9d8  orange_9d8
R9d8        43.435526  -4.348098   39.087427   41.577271
B9d8        -4.348098  43.562640   39.214541   -3.746756
purple_9d8  39.087427  39.214541   78.301969   37.830515
orange_9d8  41.577271  -3.746756   37.830515   86.378345

The covariance matrix contains one covariance value for each pair of variables. Covariance depends on the scale of measurement, so covariance values are often more difficult to compare across variables.

correlation_matrix = (
    df_RBY_9d8[
        [
            "R9d8",
            "B9d8",
            "purple_9d8",
            "orange_9d8"
        ]
    ]
    .corr()
)

print(correlation_matrix)
                R9d8      B9d8  purple_9d8  orange_9d8
R9d8        1.000000 -0.099959    0.670237    0.678783
B9d8       -0.099959  1.000000    0.671435   -0.061080
purple_9d8  0.670237  0.671435    1.000000    0.459995
orange_9d8  0.678783 -0.061080    0.459995    1.000000

The correlation is a normalization of covariance. Correlation coefficients lie between -1 and 1. A value close to 0 indicates a weak relationship, e.g. 2 independent variables should have a correlation near 0 in a large sample. A correlation near 1 indicates a strong positive linear relationship, and a variable is always perfectly correlated with itself (corr(X,X)=1). A -1 indicates a strong negative linear relationship (corr(X,-X)=-1)

Comparing regression, covariance and correlation

All three tools capture some notion of co-movement between variables. For example, in orange_9d8 ~ purple_9d8, the slope coefficient is positive because larger values of purple_9d8 tend to be associated with larger values of orange_9d8. Likewise, the covariance and correlation between purple_9d8 and orange_9d8 are positive. All three statistics therefore point in the same direction. However, important differences remain.

Correlation and covariance are symmetric: for any two variables X and Y, cov(X,Y) = cov(Y,X) and corr(X,Y) = corr(Y,X) Regression is different. The regression: orange_9d8 ~ purple_9d8 is fundamentally not the same as: purple_9d8 ~ orange_9d8. The two regressions typically produce different slope coefficients (and their product need not equal 1 either).

One of the most important limitations of correlation is that it only studies pairwise relationships. Consider B9d8 and orange_9d8. The variables were generated independently, so a near-zero correlation is the correct description of their pairwise relationship. However, in orange_9d8 ~ purple_9d8 + B9d8, the coefficient on B9d8 was informative and the regression results changed relative to orange_9d8 ~ purple_9d8 without B9d8. The fact that ‘corr(B9d8, orange_9d8)’ is approximately zero does not imply that B9d8 contains no relevant information!

Warning: Perfect multicollinearity

Consider the regression using:

  • purple_9d8
  • R9d8
  • B9d8

to predict:

  • orange_9d8
results_collinear = smf.ols(
    "orange_9d8 ~ purple_9d8 + R9d8 + B9d8",
    data=df_RBY_9d8
).fit()

print(results_collinear.summary())
                            OLS Regression Results                            
==============================================================================
Dep. Variable:             orange_9d8   R-squared:                       0.461
Model:                            OLS   Adj. R-squared:                  0.453
Method:                 Least Squares   F-statistic:                     62.81
Date:                Wed, 26 Aug 2026   Prob (F-statistic):           1.92e-20
Time:                        21:24:55   Log-Likelihood:                -500.42
No. Observations:                 150   AIC:                             1007.
Df Residuals:                     147   BIC:                             1016.
Df Model:                           2                                         
Covariance Type:            nonrobust                                         
==============================================================================
                 coef    std err          t      P>|t|      [0.025      0.975]
------------------------------------------------------------------------------
Intercept     42.0304      5.204      8.077      0.000      31.747      52.314
purple_9d8     0.3226      0.042      7.607      0.000       0.239       0.406
R9d8           0.6356      0.061     10.359      0.000       0.514       0.757
B9d8          -0.3130      0.061     -5.106      0.000      -0.434      -0.192
==============================================================================
Omnibus:                        2.322   Durbin-Watson:                   2.001
Prob(Omnibus):                  0.313   Jarque-Bera (JB):                2.312
Skew:                          -0.296   Prob(JB):                        0.315
Kurtosis:                       2.861   Cond. No.                     7.04e+16
==============================================================================

Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
[2] The smallest eigenvalue is 3.04e-28. This might indicate that there are
strong multicollinearity problems or that the design matrix is singular.

The last line in the summary “[2] The smallest eigenvalue is a very small number. This might indicate that there are strong multicollinearity problems or that the design matrix is singular.” is the only warning we get.

The problem is that purple_9d8 = R9d8 + B9d8, so there is perfect multicollinearity: there is no unique vector of coefficients minimizing the sum of squared residuals, so strictly speaking the OLS estimate is not well-defined. Arguably, sm.OLS.fit() should not even return a result in this situation. Other stats packages e.g. Stata would inform the user that there is a problem and would automatically drop one variable from the regression. You can read more in econometrics textbooks about why multicollinearity is an issue, but for now let’s just take this as a cautionary lesson about trusting results without inspecting the summary.

Repeated sampling under a true null hypothesis

We now investigate what happens when a regression coefficient is truly zero in the data generating process, but the researcher does not know that.

The data-generating process is:

orange_9d8 = R9d8 + Y9d8

The variable B9d8 is generated independently from R9d8 and Y9d8 and therefore has no effect on orange_9d8. Its true population coefficient is exactly zero.

Nevertheless, every sample is different. Sampling variation produces different coefficient estimates, t statistics, p-values, and confidence intervals. So it is possible, purely through randomness, for an estimated regression to feature a significant coefficient on B9d8. We can observe this via simulation.

n_replications = 1000
n_observations = 150
significance_threshold=0.05

slope_estimates = []
p_values = []
t_statistics = []
confidence_interval_lower_bounds = []
confidence_interval_upper_bounds = []

for replication in range(n_replications):

    R9d8 = []
    B9d8 = []
    Y9d8 = []

    for i in range(n_observations):

        R9d8.append(roll_9d8())
        B9d8.append(roll_9d8())
        Y9d8.append(roll_9d8())

    df_sim = pd.DataFrame({
        "R9d8": R9d8,
        "B9d8": B9d8,
        "Y9d8": Y9d8
    })

    df_sim["orange_9d8"] = (
        df_sim["R9d8"]
        +
        df_sim["Y9d8"]
    )

    results = smf.ols(
        "orange_9d8 ~ B9d8 + R9d8",
        data=df_sim
    ).fit()

    slope_estimates.append(
        results.params["B9d8"]
    )

    p_values.append(
        results.pvalues["B9d8"]
    )

    t_statistics.append(
        results.tvalues["B9d8"]
    )

    confidence_interval = (
        results.conf_int().loc["B9d8"]
    )

    confidence_interval_lower_bounds.append(
        confidence_interval[0]
    )

    confidence_interval_upper_bounds.append(
        confidence_interval[1]
    )

Distribution of coefficient estimates

The histogram below shows the sampling distribution of the estimated B9d8 coefficient.

plt.figure(figsize=(8,6))

plt.hist(
    slope_estimates,
    bins=50,
    edgecolor="black"
)

plt.axvline(
    0,
    color="red",
    linewidth=2
)

plt.xlabel("Estimated slope")
plt.ylabel("Count")

plt.title(
    "Histogram of slope estimates (true slope = 0)"
)

plt.show()
plt.close()

Alternatively, we can use a scatterplot to visualize the estimates and their p-values.

significant_slopes = []
significant_pvalues = []

nonsignificant_slopes = []
nonsignificant_pvalues = []

for slope, pvalue in zip(
    slope_estimates,
    p_values
):

    if pvalue < significance_threshold:

        significant_slopes.append(
            slope
        )

        significant_pvalues.append(
            pvalue
        )

    else:

        nonsignificant_slopes.append(
            slope
        )

        nonsignificant_pvalues.append(
            pvalue
        )

plt.figure(figsize=(8,6))

plt.scatter(
    nonsignificant_slopes,
    nonsignificant_pvalues,
    color="gray",
    alpha=0.5,
    s=10,
    label=f"Not significant at {significance_threshold}"
)

plt.scatter(
    significant_slopes,
    significant_pvalues,
    color="red",
    alpha=0.8,
    s=10,
    label="significant p-value < {significance_threshold}"
)

plt.axhline(
    significance_threshold,
    color="black",
    linestyle="--",
    linewidth=2
)

plt.axvline(
    0,
    color="blue",
    linestyle="--",
    linewidth=2
)

plt.xlabel("Estimated slope")
plt.ylabel("p-value")

plt.title(
    "Repeated regressions of orange_9d8 on B9d8\nTrue slope always = 0"
)

plt.legend(loc="upper right")

plt.show()
plt.close()

All red points correspond to statistically significant estimates even though the true coefficient is zero in the data generating process.

A 0.05 significance threshold for rejecting the null hypothesis of coefficient=0 means that around 5% of the time, the regression will produce a significant estimate purely due to random chance.

We can compute the frequency of such “false significance” events:

n_significant = 0

for pvalue in p_values:

    if pvalue < significance_threshold:

        n_significant += 1

false_significant_rate = (
    n_significant
    /
    len(p_values)
)

print(
    "Fraction significant:",
    round(false_significant_rate, 4)
)
Fraction significant: 0.042

The observed fraction should be close to significance_threshold when n_replications is high.

Confidence intervals under a true null hypothesis

Here is a plot of the first 100 confidence intervals for the coefficient on B9d8. Intervals that fail to include zero correspond to statistically significant results.

n_intervals_to_plot = 100

plt.figure(figsize=(8,12))

for interval_index in range(
    n_intervals_to_plot
):

    lower_bound = (
        confidence_interval_lower_bounds[
            interval_index
        ]
    )

    upper_bound = (
        confidence_interval_upper_bounds[
            interval_index
        ]
    )

    excludes_zero = (
        lower_bound > 0
        or
        upper_bound < 0
    )

    if excludes_zero:

        color = "red"

    else:

        color = "gray"

    plt.plot(
        [lower_bound, upper_bound],
        [interval_index, interval_index],
        color=color
    )

plt.axvline(
    0,
    color="black",
    linestyle="--"
)

plt.xlabel(
    "Estimated coefficient"
)

plt.ylabel(
    "Simulation"
)

plt.title(
    "95% confidence intervals for the B9d8 coefficient (true slope=0)"
)

plt.show()
plt.close()

The red intervals exclude zero and therefore correspond to statistically significant estimates. The gray intervals contain zero and therefore do not lead to rejecting the null hypothesis.

Full script (all code blocks stacked)


#%% Constructing a dataset 39d8 and secondary colors

## Imagine three sets of colored d8:
## - 9 red d8
## - 9 blue d8
## - 9 yellow d8

## For each observation:

## 1. Roll the 9 red d8 and record their sum as `R9d8`.
## 2. Roll the 9 blue d8 and record their sum as `B9d8`.
## 3. Roll the 9 yellow d8 and record their sum as `Y9d8`.

## The variables `R9d8`, `B9d8`, and `Y9d8` are our **primary variables**.

## We then construct two **secondary variables** by combining primary
## variables.

## - `purple_9d8 = R9d8 + B9d8`
## - `orange_9d8 = R9d8 + Y9d8`


import random
import pandas as pd
import matplotlib.pyplot as plt
random.seed(989898)

def roll_9d8():

    total = 0

    for i in range(9):
        total += random.randint(1, 8)

    return total

n_observations = 150

R9d8 = []
B9d8 = []
Y9d8 = []

for i in range(n_observations):

    R9d8.append(roll_9d8())
    B9d8.append(roll_9d8())
    Y9d8.append(roll_9d8())

df_RBY_9d8 = pd.DataFrame({
    "R9d8": R9d8,
    "B9d8": B9d8,
    "Y9d8": Y9d8
})

df_RBY_9d8["purple_9d8"] = (
    df_RBY_9d8["R9d8"]
    + df_RBY_9d8["B9d8"]
)

df_RBY_9d8["orange_9d8"] = (
    df_RBY_9d8["R9d8"]
    + df_RBY_9d8["Y9d8"]
)

print(df_RBY_9d8.head())


## The dataframe contains one observation per row and one variable per
## column.

#%% Marginal distributions

## The next figure displays the distribution of each variable separately.


fig, axes = plt.subplots(
    5,
    1,
    figsize=(6,30)
)

variables = [
    "R9d8",
    "B9d8",
    "Y9d8",
    "purple_9d8",
    "orange_9d8"
]

colornames = [
    "red",
    "blue",
    "yellow",
    "purple",
    "orange"
]

for ax, variable, colorname in zip(
    axes,
    variables,
    colornames
):

    counts = (
        df_RBY_9d8[variable]
        .value_counts()
        .sort_index()
    )

    ax.bar(
        counts.index,
        counts.values,
        color=colorname
    )

    ax.set_title(variable)
    ax.set_ylabel("Count")

plt.tight_layout()

plt.show()
plt.close()

print(df_RBY_9d8.describe())


#%% Pairwise relationships

## The next figure displays scatterplots for three variable pairs:

## - `R9d8` and `B9d8`
## - `R9d8` and `orange_9d8`
## - `purple_9d8` and `orange_9d8`

fig, axes = plt.subplots(
    3,
    1,
    figsize=(6,18)
)

axes[0].scatter(
    df_RBY_9d8["R9d8"],
    df_RBY_9d8["B9d8"],
    s=10
)

axes[0].set_xlabel("R9d8")
axes[0].set_ylabel("B9d8")

axes[1].scatter(
    df_RBY_9d8["R9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10
)

axes[1].set_xlabel("R9d8")
axes[1].set_ylabel("orange_9d8")

axes[2].scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10
)

axes[2].set_xlabel("purple_9d8")
axes[2].set_ylabel("orange_9d8")

plt.tight_layout()

plt.show()
plt.close()


#%% The prediction metaphor

## When running regressions, our ongoing metaphor will be that we try to
## **predict** `orange_9d8`. This does not refer to literal prediction as
## in "predict the future": we have the observations of `orange_9d8`
## already, so we do not have to "predict" anything. We also know exactly
## how `orange_9d8` was constructed, and if both `R9d8` and `Y9d8` are
## available, "predicting" `orange_9d8` from them is trivial.

## One interpretation of "predict" is out-of-sample prediction: we try to
## guess what the value of the hypothetical next observation would be.

## The other interpretation of "predict" is metaphorical: "predict" stands
## for "document relationships between variables and determine how
## observing one variable is informative about another". Outside of
## simulation settings, researchers observe a dataset but do not observe
## the data-generating process. We place ourselves in the position of such
## a researcher, and represent ignorance of the data-generating process by
## restricting access to some variables and working with subsets of the
## full dataframe.

#%% Predicting orange9d8 from nothing

## Suppose the only available variable is:

## - `orange_9d8`

## All other variables are hidden.

## The dataframe below contains exactly that variable.

df_orange_only = (
    df_RBY_9d8[
        ["orange_9d8"]
    ]
)

print(df_orange_only.head())


## The histogram below displays the distribution of `orange_9d8`. Without
## access to any other variables, a natural prediction is the sample
## average of `orange_9d8`. The vertical line indicates the sample average.


orange_9d8_mean = (
    df_orange_only["orange_9d8"]
    .mean()
)


orange_counts = (
    df_orange_only["orange_9d8"]
    .value_counts()
    .sort_index()
)

plt.bar(
    orange_counts.index,
    orange_counts.values,
    color="orange"
)

plt.axvline(
    orange_9d8_mean,
    color="black"
)

plt.xlabel("orange_9d8")
plt.ylabel("Count")

plt.show()
plt.close()


print("Sample mean of orange_9d8:", orange_9d8_mean)




#%% Predicting orange9d8 from B9d8

## Suppose the available variables are:

## - `B9d8`
## - `orange_9d8`

## The remaining variables are hidden.

## The dataframe below contains exactly those variables.

df_B = (
    df_RBY_9d8[
        [
            "B9d8",
            "orange_9d8"
        ]
    ]
)

print(df_B.head())


## The scatterplot below displays all observed pairs of values.

plt.figure(figsize=(6,6))

plt.scatter(
    df_B["B9d8"],
    df_B["orange_9d8"],
    s=10
)

plt.xlabel("B9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()


## We now compute the line of best fit relating `orange_9d8` to `B9d8`. For
## that we load the `statsmodels.api` library

import statsmodels.formula.api as smf

results_B = smf.ols(
    "orange_9d8 ~ B9d8",
    data=df_B
).fit()

print(results_B.summary())


## The summary table reports:

## - coefficient estimates
## - standard errors
## - t statistics
## - confidence intervals
## - R²
## - additional diagnostics

## The next figure displays the scatterplot together with the fitted line.

intercept_B = (
    results_B.params["Intercept"]
)

slope_B = (
    results_B.params["B9d8"]
)

x_line_B = list(
    range(
        int(df_B["B9d8"].min()),
        int(df_B["B9d8"].max()) + 1
    )
)

y_line_B = [
    intercept_B + slope_B * x
    for x in x_line_B
]

plt.figure(figsize=(6,6))

plt.scatter(
    df_B["B9d8"],
    df_B["orange_9d8"],
    s=10
)

plt.plot(
    x_line_B,
    y_line_B,
    color="red"
)

plt.xlabel("B9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()


#%% Predicting orange9d8 from R9d8

## Suppose the available variables are:

## - `R9d8`
## - `orange_9d8`

## The remaining variables are hidden.

## The dataframe below contains exactly those variables.

df_R = (
    df_RBY_9d8[
        [
            "R9d8",
            "orange_9d8"
        ]
    ]
)

print(df_R.head())


## The scatterplot below displays all observed pairs of values.

plt.figure(figsize=(6,6))

plt.scatter(
    df_R["R9d8"],
    df_R["orange_9d8"],
    s=10
)

plt.xlabel("R9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()


## We now compute the line of best fit relating `orange_9d8` to `R9d8`.

results_R = smf.ols(
    "orange_9d8 ~ R9d8",
    data=df_R
).fit()

print(results_R.summary())


## The next figure displays the scatterplot together with the fitted line.

intercept_R = (
    results_R.params["Intercept"]
)

slope_R = (
    results_R.params["R9d8"]
)

x_line_R = list(
    range(
        int(df_R["R9d8"].min()),
        int(df_R["R9d8"].max()) + 1
    )
)

y_line_R = [
    intercept_R + slope_R * x
    for x in x_line_R
]

plt.figure(figsize=(6,6))

plt.scatter(
    df_R["R9d8"],
    df_R["orange_9d8"],
    s=10
)

plt.plot(
    x_line_R,
    y_line_R,
    color="red"
)

plt.xlabel("R9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()


#%% Predicting orange9d8 from purple9d8

## Suppose the available variables are:

## - `purple_9d8`
## - `orange_9d8`

## The remaining variables are hidden.

## The dataframe below contains exactly those variables.

df_purple = (
    df_RBY_9d8[
        [
            "purple_9d8",
            "orange_9d8"
        ]
    ]
)

print(df_purple.head())


## The scatterplot below displays all observed pairs of values.

plt.figure(figsize=(6,6))

plt.scatter(
    df_purple["purple_9d8"],
    df_purple["orange_9d8"],
    s=10
)

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()


## We now compute the line of best fit relating `orange_9d8` to
## `purple_9d8`.

results_purple = smf.ols(
    "orange_9d8 ~ purple_9d8",
    data=df_purple
).fit()

print(results_purple.summary())


## The next figure displays the scatterplot together with the fitted line.

intercept_purple = (
    results_purple.params["Intercept"]
)

slope_purple = (
    results_purple.params["purple_9d8"]
)

x_line_purple = list(
    range(
        int(df_purple["purple_9d8"].min()),
        int(df_purple["purple_9d8"].max()) + 1
    )
)

y_line_purple = [
    intercept_purple + slope_purple * x
    for x in x_line_purple
]

plt.figure(figsize=(6,6))

plt.scatter(
    df_purple["purple_9d8"],
    df_purple["orange_9d8"],
    s=10
)

plt.plot(
    x_line_purple,
    y_line_purple,
    color="red"
)

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()


## The regression summary reports the estimated relationship between
## `purple_9d8` and `orange_9d8`.

## The regression of `orange_9d8` on `purple_9d8` will serve as the
## benchmark regression in the next sections.

#%% Offramp The intercept

## The regression summary contains a coefficient labelled `const`.

## This coefficient is the intercept of the regression line.

## The intercept is the predicted value of `orange_9d8` when the predictor
## equals zero.

## To see where this value appears on the graph, we redraw the scatterplot
## and force the horizontal axis to include zero.

plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10
)

# Solid part: observed support
plt.plot(
    x_line_purple,
    y_line_purple,
    color="red"
)

# Dashed extrapolation to the intercept
x_line_intercept = [
    0,
    x_line_purple[0]
]

y_line_intercept = [
    intercept_purple,
    intercept_purple + slope_purple * x_line_purple[0]
]

plt.plot(
    x_line_intercept,
    y_line_intercept,
    color="red",
    linestyle="--"
)

plt.scatter(
    [0],
    [intercept_purple],
    color="black",
    s=80,
    zorder=10
)

plt.xlim(left=-10)

plt.ylim(
    min(
        intercept_purple - 10,
        df_RBY_9d8["orange_9d8"].min()
    ),
    df_RBY_9d8["orange_9d8"].max() + 5
)

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()


## The black point is located at: `(0, Intercept)`. It lies on the fitted
## regression line, albeit in a region of the scatterplot where there are
## no observations. The coefficient labelled `Intercept` therefore
## determines where the regression line crosses the vertical axis.

#%% Offramp Centering the predictor

## The meaning of the intercept depends on how the predictor is measured.

## We now create a centered version of `purple_9d8` by subtracting its
## sample mean.

purple_9d8_mean = (
    df_RBY_9d8["purple_9d8"]
    .mean()
)

print(
    "Sample mean of purple_9d8:",
    purple_9d8_mean
)

df_RBY_9d8["purple_9d8_centered"] = (
    df_RBY_9d8["purple_9d8"]
    -
    purple_9d8_mean
)

print(
    df_RBY_9d8[
        [
            "purple_9d8",
            "purple_9d8_centered"
        ]
    ].head()
)


## The centered variable measures distance from the sample mean.

## A value of:

## `purple_9d8_centered = 0`

## corresponds to an observation whose value of `purple_9d8` equals the
## sample mean.

## We now rerun the regression using the centered predictor.

results_centered = smf.ols(
    "orange_9d8 ~ purple_9d8_centered",
    data=df_RBY_9d8
).fit()


## The next figure displays the data in the original `purple_9d8` scale
## together with the fitted line from the centered regression.

centercept = (
    results_centered.params["Intercept"]
)

slope_centered = (
    results_centered.params[
        "purple_9d8_centered"
    ]
)

x_line_centered = list(
    range(
        int(
            df_RBY_9d8[
                "purple_9d8"
            ].min()
        ),
        int(
            df_RBY_9d8[
                "purple_9d8"
            ].max()
        ) + 1
    )
)

y_line_centered = [
    centercept
    +
    slope_centered
    *
    (
        x
        -
        purple_9d8_mean
    )
    for x in x_line_centered
]

plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10
)

plt.plot(
    x_line_centered,
    y_line_centered,
    color="red"
)

plt.scatter(
    [purple_9d8_mean],
    [centercept],
    color="black",
    s=80,
    zorder=10
)

plt.axvline(
    purple_9d8_mean,
    color="black",
    linestyle="--"
)

plt.axhline(
    centercept,
    color="black",
    linestyle="--"
)

plt.xlabel(
    "purple_9d8"
)

plt.ylabel(
    "orange_9d8"
)

plt.grid(alpha=0.3)

plt.show()
plt.close()


## The black point has coordinates:

## `(sample mean of purple_9d8, Intercept)`

## We now print the regression summary.

print(
    results_centered.summary()
)


## The coefficient labelled `Intercept` is the fitted value when:

## `purple_9d8_centered = 0`

## Since `purple_9d8_centered` was constructed by subtracting the sample
## mean of `purple_9d8`, the value:

## `purple_9d8_centered = 0`

## corresponds to:

## `purple_9d8 = sample mean of purple_9d8`

## For this reason, the intercept in the centered regression is sometimes
## informally called a "centercept".


#%% Fitted values Residuals Ordinary Least Squares

## The fitted values are the values predicted by the regression line.

## They are computed directly from the estimated intercept and slope.

df_RBY_9d8["orange_9d8_predicted"] = (
    results_purple.fittedvalues
)

print(
    df_RBY_9d8[
        [
            "purple_9d8",
            "orange_9d8",
            "orange_9d8_predicted"
        ]
    ].head()
)


## The table displays:

## - the predictor `purple_9d8`
## - the observed value `orange_9d8`
## - the fitted value `orange_9d8_predicted`

## The fitted values lie on the regression line.

## For each observation, the residual is defined as:

## `residual = observed value - fitted value`

df_RBY_9d8["orange_9d8_residual"] = (
    df_RBY_9d8["orange_9d8"]
    -
    df_RBY_9d8["orange_9d8_predicted"]
)

print(
    df_RBY_9d8[
        [
            "orange_9d8",
            "orange_9d8_predicted",
            "orange_9d8_residual"
        ]
    ].head()
)


## The table displays:

## - the observed value
## - the fitted value
## - the residual

## Every observation now has both a prediction and an associated prediction
## error.
## The table displays:

## - the observed value
## - the fitted value
## - the residual

## Every observation now has both a prediction and an associated prediction
## error.

## The residual for an observation is the vertical distance between the
## observed point and the fitted line.

## The graph below displays a subset of residuals.

plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10
)

plt.plot(
    x_line_purple,
    y_line_purple,
    color="red"
)

for i in range(20):

    plt.plot(
        [
            df_RBY_9d8["purple_9d8"].iloc[i],
            df_RBY_9d8["purple_9d8"].iloc[i]
        ],
        [
            df_RBY_9d8["orange_9d8"].iloc[i],
            df_RBY_9d8["orange_9d8_predicted"].iloc[i]
        ],
        color="gray"
    )

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8")

plt.show()
plt.close()


## Each gray segment corresponds to one residual.

## The regression line is computed using Ordinary Least Squares (OLS).

## OLS chooses the intercept and slope that minimize the sum of squared
## residuals.

## We can compute that quantity directly from the residuals.

SSR = (
    df_RBY_9d8["orange_9d8_residual"]**2
).sum()

print(SSR)


## The fitted regression line is the line with the smallest possible value
## of the sum of squared residuals among all straight lines.

#%% Residuals versus fitted values

## The residuals can be plotted against the fitted values produced by the
## regression.

## The horizontal line corresponds to a residual equal to zero.

plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["orange_9d8_predicted"],
    df_RBY_9d8["orange_9d8_residual"],
    s=10
)

plt.axhline(
    0,
    color="red"
)

plt.xlabel("orange_9d8_predicted")
plt.ylabel("orange_9d8_residual")

plt.grid(alpha=0.3)

plt.show()
plt.close()


## Each point corresponds to one observation.

## The horizontal position records the fitted value.

## The vertical position records the corresponding residual.



#%% Residuals versus purple9d8

## The residuals can also be plotted against the predictor used in the
## regression.

plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8_residual"],
    s=10
)

plt.axhline(
    0,
    color="red"
)

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8_residual")

plt.grid(alpha=0.3)

plt.show()
plt.close()


## Each point corresponds to one observation.

## The horizontal position records the value of `purple_9d8`.

## The vertical position records the residual from the benchmark
## regression.



#%% Offramp Conditional means

## The regression line is one way of summarizing the relationship between
## two variables.

## Another approach is to compute conditional means.

## For each observed value of `purple_9d8`, we compute the average value of
## `orange_9d8`.

conditional_means = (
    df_RBY_9d8
    .groupby("purple_9d8")
    ["orange_9d8"]
    .mean()
    .reset_index()
)

print(
    conditional_means.head()
)


## The table contains:

## - a value of `purple_9d8`
## - the corresponding average value of `orange_9d8`

## Each row summarizes a subset of observations from the dataset.

## The conditional means can be added to the scatterplot.

plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10,
    alpha=0.3
)

plt.plot(
    conditional_means["purple_9d8"],
    conditional_means["orange_9d8"],
    color="red",
    linewidth=2
)

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()


## The red curve joins the conditional means.

#%% Conditional means and regression

## The next figure displays:

## - observations
## - conditional means
## - regression line

plt.figure(figsize=(6,6))

plt.scatter(
    df_RBY_9d8["purple_9d8"],
    df_RBY_9d8["orange_9d8"],
    s=10,
    alpha=0.3
)

plt.plot(
    conditional_means["purple_9d8"],
    conditional_means["orange_9d8"],
    color="red",
    linewidth=2,
    label="Conditional means"
)

plt.plot(
    x_line_purple,
    y_line_purple,
    color="black",
    linewidth=2,
    label="Regression line"
)

plt.legend()

plt.xlabel("purple_9d8")
plt.ylabel("orange_9d8")

plt.grid(alpha=0.3)

plt.show()
plt.close()


## The conditional means and the regression line are constructed from the
## same pair of variables:

## - `purple_9d8`
## - `orange_9d8`

## The conditional means use one average for each observed value of
## `purple_9d8`.

## The regression uses a single straight line.

#%% Multiple predictors setup

## So far, we have estimated several regressions of orange_9d8 on a single
## variable.
## We now turn to multiple regression, with more than one variable. To make
## comparisons easier, we will progressively build a regression results
## table, with only key results, for ease of comparison. However, we should
## not skip printing full regression summaries, so we will progressively
## add them to a large appendix document, to be printed as an appendix.

#%% Initializing the regression results table and appendix

## The rows of the table correspond to quantities reported by regression
## summaries.
## The columns correspond to different regression specifications.
## A helper function adds the results from a regression to a new column.

regression_results_table = pd.DataFrame(
    index=[
        "Intercept_coef",
        "Intercept_P>|t|",
        "",          #### Blank rows make the table more readable
        "R9d8_coef",
        "R9d8_P>|t|",
       "",
        "B9d8_coef",
        "B9d8_P>|t|",
        "",
        "purple_9d8_coef",
        "purple_9d8_P>|t|",
        "",
        "R-squared",
        "Adj. R-squared",
        "No. Observations"
    ]
)

regression_summaries = {}

def add_regression_to_table(
    table,
    column_name,
    results
):

    column = pd.Series(index=table.index, dtype=float)

    for variable in results.params.index:
        column[f"{variable}_coef"] = results.params[variable]
        column[f"{variable}_P>|t|"] = results.pvalues[variable]

    column["R-squared"] = results.rsquared
    column["Adj. R-squared"] = results.rsquared_adj
    column["No. Observations"] = results.nobs

    table[column_name] = column
    
    regression_summaries[column_name] = (
        results.summary()
    )



## We begin by adding existing regression results to the table.


add_regression_to_table(
    regression_results_table,
    "B",
    results_B
)

add_regression_to_table(
    regression_results_table,
    "R",
    results_R
)

add_regression_to_table(
    regression_results_table,
    "purple",
    results_purple
)

print(regression_results_table.round(3).fillna(""))


#%% Multiple predictors

## We now add `B9d8` to the regression that previously used only `R9d8`.

results_RB = smf.ols(
    "orange_9d8 ~ R9d8 + B9d8",
    data=df_RBY_9d8
).fit()

add_regression_to_table(
    regression_results_table,
    "RB",
    results_RB
)

print(regression_results_table.round(3).fillna(""))



results_purple_R = smf.ols(
    "orange_9d8 ~ purple_9d8 + R9d8",
    data=df_RBY_9d8
).fit()

add_regression_to_table(
    regression_results_table,
    "purple R",
    results_purple_R
)

print(regression_results_table.round(3).fillna(""))



results_purple_B = smf.ols(
    "orange_9d8 ~ purple_9d8 + B9d8",
    data=df_RBY_9d8
).fit()


add_regression_to_table(
    regression_results_table,
    "purple B",
    results_purple_B
)

print(regression_results_table.round(3).fillna(""))


## The table provides a compact summary of the regressions estimated so
## far.

## The complete regression summaries are gathered as an appendix. There is
## sometimes important information contained in them (see later section on
## multicollinearity).
for column_name, regression_summary in regression_summaries.items():
    print(f"Regression summary for column {column_name}:")
    print(regression_summary)
    print(f"\n\n")


#%% Complement Correlation and covariance
## Correlation and covariance provide numerical summaries of pairwise
## relationships between variables. They can be computed automatically by
## pandas dataframe:

covariance_matrix = (
    df_RBY_9d8[
        [
            "R9d8",
            "B9d8",
            "purple_9d8",
            "orange_9d8"
        ]
    ]
    .cov()
)

print(covariance_matrix)


## The covariance matrix contains one covariance value for each pair of
## variables. Covariance depends on the scale of measurement, so covariance
## values are often more difficult to compare across variables.

correlation_matrix = (
    df_RBY_9d8[
        [
            "R9d8",
            "B9d8",
            "purple_9d8",
            "orange_9d8"
        ]
    ]
    .corr()
)

print(correlation_matrix)


## The correlation is a normalization of covariance. Correlation
## coefficients lie between -1 and 1. A value close to 0 indicates a weak
## relationship, e.g. 2 independent variables should have a correlation
## near 0 in a large sample.
## A correlation near 1 indicates a strong positive linear relationship,
## and a variable is always perfectly correlated with itself (corr(X,X)=1).
## A -1 indicates a strong negative linear relationship (corr(X,-X)=-1)

#%% Comparing regression covariance and correlation

## All three tools capture some notion of co-movement between variables.
## For example, in `orange_9d8 ~ purple_9d8`, the slope coefficient is
## positive because larger values of `purple_9d8` tend to be associated
## with larger values of `orange_9d8`. Likewise, the covariance and
## correlation between `purple_9d8` and `orange_9d8` are positive. All
## three statistics therefore point in the same direction. However,
## important differences remain.

## Correlation and covariance are symmetric: for any two variables X and Y,
## `cov(X,Y) = cov(Y,X)` and `corr(X,Y) = corr(Y,X)`
## Regression is different. The regression: `orange_9d8 ~ purple_9d8` is
## fundamentally not the same as: `purple_9d8 ~ orange_9d8`. The two
## regressions typically produce different slope coefficients (and their
## product need not equal 1 either).

## One of the most important limitations of correlation is that it only
## studies pairwise relationships. Consider `B9d8` and `orange_9d8`. The
## variables were generated independently, so a near-zero correlation is
## the correct description of their pairwise relationship. However, in
## `orange_9d8 ~ purple_9d8 + B9d8`, the coefficient on `B9d8` was
## informative and the regression results changed relative to `orange_9d8 ~
## purple_9d8` without `B9d8`. The fact that 'corr(B9d8, orange_9d8)' is
## approximately zero does not imply that `B9d8` contains no relevant
## information!

#%% Warning Perfect multicollinearity

## Consider the regression using:

## - `purple_9d8`
## - `R9d8`
## - `B9d8`

## to predict:

## - `orange_9d8`

results_collinear = smf.ols(
    "orange_9d8 ~ purple_9d8 + R9d8 + B9d8",
    data=df_RBY_9d8
).fit()

print(results_collinear.summary())


## The last line in the summary "[2] The smallest eigenvalue is `a very
## small number`. This might indicate that there are
## strong multicollinearity problems or that the design matrix is
## singular." is the only warning we get.

## The problem is that `purple_9d8 = R9d8 + B9d8`, so there is perfect
## multicollinearity: there is no unique vector of coefficients minimizing
## the sum of squared residuals, so strictly speaking the OLS estimate is
## not well-defined. Arguably, `sm.OLS.fit()` should not even return a
## result in this situation. Other stats packages e.g. Stata would inform
## the user that there is a problem and would automatically drop one
## variable from the regression. You can read more in econometrics
## textbooks about why multicollinearity is an issue, but for now let's
## just take this as a cautionary lesson about trusting results without
## inspecting the summary.

#%% Repeated sampling under a true null hypothesis

## We now investigate what happens when a regression coefficient is truly
## zero in the data generating process, but the researcher does not know
## that.

## The data-generating process is:

## orange_9d8 = R9d8 + Y9d8

## The variable B9d8 is generated independently from R9d8 and Y9d8 and
## therefore has no effect on orange_9d8. Its true population coefficient
## is exactly zero.

## Nevertheless, every sample is different. Sampling variation produces
## different coefficient estimates, t statistics, p-values, and confidence
## intervals. So it is possible, purely through randomness, for an
## estimated regression to feature a significant coefficient on B9d8. We
## can observe this via simulation.

n_replications = 1000
n_observations = 150
significance_threshold=0.05

slope_estimates = []
p_values = []
t_statistics = []
confidence_interval_lower_bounds = []
confidence_interval_upper_bounds = []

for replication in range(n_replications):

    R9d8 = []
    B9d8 = []
    Y9d8 = []

    for i in range(n_observations):

        R9d8.append(roll_9d8())
        B9d8.append(roll_9d8())
        Y9d8.append(roll_9d8())

    df_sim = pd.DataFrame({
        "R9d8": R9d8,
        "B9d8": B9d8,
        "Y9d8": Y9d8
    })

    df_sim["orange_9d8"] = (
        df_sim["R9d8"]
        +
        df_sim["Y9d8"]
    )

    results = smf.ols(
        "orange_9d8 ~ B9d8 + R9d8",
        data=df_sim
    ).fit()

    slope_estimates.append(
        results.params["B9d8"]
    )

    p_values.append(
        results.pvalues["B9d8"]
    )

    t_statistics.append(
        results.tvalues["B9d8"]
    )

    confidence_interval = (
        results.conf_int().loc["B9d8"]
    )

    confidence_interval_lower_bounds.append(
        confidence_interval[0]
    )

    confidence_interval_upper_bounds.append(
        confidence_interval[1]
    )


#%% Distribution of coefficient estimates

## The histogram below shows the sampling distribution of the estimated
## B9d8 coefficient.

plt.figure(figsize=(8,6))

plt.hist(
    slope_estimates,
    bins=50,
    edgecolor="black"
)

plt.axvline(
    0,
    color="red",
    linewidth=2
)

plt.xlabel("Estimated slope")
plt.ylabel("Count")

plt.title(
    "Histogram of slope estimates (true slope = 0)"
)

plt.show()
plt.close()

## Alternatively, we can use a scatterplot to visualize the estimates and
## their p-values.

significant_slopes = []
significant_pvalues = []

nonsignificant_slopes = []
nonsignificant_pvalues = []

for slope, pvalue in zip(
    slope_estimates,
    p_values
):

    if pvalue < significance_threshold:

        significant_slopes.append(
            slope
        )

        significant_pvalues.append(
            pvalue
        )

    else:

        nonsignificant_slopes.append(
            slope
        )

        nonsignificant_pvalues.append(
            pvalue
        )

plt.figure(figsize=(8,6))

plt.scatter(
    nonsignificant_slopes,
    nonsignificant_pvalues,
    color="gray",
    alpha=0.5,
    s=10,
    label=f"Not significant at {significance_threshold}"
)

plt.scatter(
    significant_slopes,
    significant_pvalues,
    color="red",
    alpha=0.8,
    s=10,
    label="significant p-value < {significance_threshold}"
)

plt.axhline(
    significance_threshold,
    color="black",
    linestyle="--",
    linewidth=2
)

plt.axvline(
    0,
    color="blue",
    linestyle="--",
    linewidth=2
)

plt.xlabel("Estimated slope")
plt.ylabel("p-value")

plt.title(
    "Repeated regressions of orange_9d8 on B9d8\nTrue slope always = 0"
)

plt.legend(loc="upper right")

plt.show()
plt.close()


## All red points correspond to statistically significant estimates even
## though the true coefficient is zero in the data generating process.

## A 0.05 significance threshold for rejecting the null hypothesis of
## coefficient=0 means that around 5% of the time, the regression will
## produce a significant estimate purely due to random chance.

## We can compute the frequency of such "false significance" events:

n_significant = 0

for pvalue in p_values:

    if pvalue < significance_threshold:

        n_significant += 1

false_significant_rate = (
    n_significant
    /
    len(p_values)
)

print(
    "Fraction significant:",
    round(false_significant_rate, 4)
)


## The observed fraction should be close to `significance_threshold` when
## `n_replications` is high.

#%% Confidence intervals under a true null hypothesis

## Here is a plot of the first 100 confidence intervals for the coefficient
## on `B9d8`. Intervals that fail to include zero correspond to
## statistically significant results.

n_intervals_to_plot = 100

plt.figure(figsize=(8,12))

for interval_index in range(
    n_intervals_to_plot
):

    lower_bound = (
        confidence_interval_lower_bounds[
            interval_index
        ]
    )

    upper_bound = (
        confidence_interval_upper_bounds[
            interval_index
        ]
    )

    excludes_zero = (
        lower_bound > 0
        or
        upper_bound < 0
    )

    if excludes_zero:

        color = "red"

    else:

        color = "gray"

    plt.plot(
        [lower_bound, upper_bound],
        [interval_index, interval_index],
        color=color
    )

plt.axvline(
    0,
    color="black",
    linestyle="--"
)

plt.xlabel(
    "Estimated coefficient"
)

plt.ylabel(
    "Simulation"
)

plt.title(
    "95% confidence intervals for the B9d8 coefficient (true slope=0)"
)

plt.show()
plt.close()


## The red intervals exclude zero and therefore correspond to statistically
## significant estimates. The gray intervals contain zero and therefore do
## not lead to rejecting the null hypothesis.