# Beauty and teaching evaluations
Source: `Beauty/beauty.Rmd`
Hamermesh and Parker's teaching-evaluation data ask a deliberately simple regression question: do instructors rated as more beautiful also receive higher average teaching evaluations? The R original uses `rstanarm::stan_glm`; the Python version uses the same formula-first Bayesian GLM path through `lapylace`.
## Setup and data
```{python}
from pathlib import Path
import numpy as np
import pandas as pd
import lapylace as lp
import matplotlib.pyplot as plt
root = Path("../../ROS-Examples")
beauty = pd.read_csv(root / "Beauty/data/beauty.csv")
beauty.head()
```
```{python}
def fit_gaussian(formula, seed):
return lp.stan_glm(
formula,
data=beauty,
family=lp.gaussian(),
prior=lp.normal(0, 10),
prior_intercept=lp.normal(0, 10),
prior_aux=lp.exponential(1),
chains=4,
parallel_chains=4,
iter_warmup=500,
iter_sampling=1000,
seed=seed,
refresh=100,
)
def coef_medians(fit):
return pd.Series(
[np.median(fit.alpha_draws()), *np.median(fit.beta_draws(), axis=0)],
index=["Intercept", *fit.columns],
)
def coef_sds(fit):
return pd.Series(
[np.std(fit.alpha_draws()), *np.std(fit.beta_draws(), axis=0)],
index=["Intercept", *fit.columns],
)
```
```{python}
beauty.describe(include="all")
```
## Beauty as a single predictor
```{python}
fig, ax = plt.subplots()
ax.scatter(beauty["beauty"], beauty["eval"], alpha=0.75)
ax.set_xlabel("Beauty")
ax.set_ylabel("Average teaching evaluation")
```
```{python}
fit_1 = fit_gaussian("eval ~ beauty", seed=16201)
fit_1.summary(["alpha", "beta", "sigma"])
```
```{python}
xs = np.linspace(beauty.beauty.min(), beauty.beauty.max(), 200)
coef_1 = coef_medians(fit_1)
b0, b1 = coef_1["Intercept"], coef_1["beauty"]
sigma = np.median(fit_1.stan_variables()["sigma"])
fig, ax = plt.subplots()
ax.scatter(beauty["beauty"], beauty["eval"], alpha=0.75)
ax.plot(xs, b0 + b1 * xs, color="black")
ax.plot(xs, b0 + b1 * xs + sigma, color="gray", linestyle="--")
ax.plot(xs, b0 + b1 * xs - sigma, color="gray", linestyle="--")
ax.set_xlabel("Beauty")
ax.set_ylabel("Average teaching evaluation")
ax.set_title("Linear fit with +/- one residual SD")
```
The slope is small on the evaluation scale, but positive in this simple regression.
## Parallel lines for men and women
The R page next adds an indicator for women instructors. This gives two parallel regression lines: same beauty slope, different intercepts.
```{python}
fit_2 = fit_gaussian("eval ~ beauty + female", seed=16202)
fit_2.summary(["alpha", "beta", "sigma"])
```
```{python}
coef_2 = coef_medians(fit_2)
def line_parallel(x, female):
p = coef_2
return p["Intercept"] + p["beauty"] * x + p["female"] * female
fig, axes = plt.subplots(1, 3, figsize=(12, 3.5), sharex=True, sharey=True)
for ax, female, title, color in [(axes[0], 0, "Men", "tab:blue"), (axes[1], 1, "Women", "tab:red")]:
d = beauty[beauty.female == female]
ax.scatter(d["beauty"], d["eval"], alpha=0.75, color=color)
ax.plot(xs, line_parallel(xs, female), color="black")
ax.set_title(title)
ax.set_xlabel("Beauty")
axes[0].set_ylabel("Average teaching evaluation")
axes[2].scatter(beauty.loc[beauty.female == 0, "beauty"], beauty.loc[beauty.female == 0, "eval"], color="tab:blue", alpha=0.65, label="Men")
axes[2].scatter(beauty.loc[beauty.female == 1, "beauty"], beauty.loc[beauty.female == 1, "eval"], color="tab:red", alpha=0.65, label="Women")
axes[2].plot(xs, line_parallel(xs, 0), color="tab:blue")
axes[2].plot(xs, line_parallel(xs, 1), color="tab:red")
axes[2].set_title("Both sexes")
axes[2].set_xlabel("Beauty")
axes[2].legend(frameon=False)
```
## Allow the beauty slope to differ by sex
```{python}
fit_3 = fit_gaussian("eval ~ beauty * female", seed=16203)
fit_3.summary(["alpha", "beta", "sigma"])
```
```{python}
coef_3 = coef_medians(fit_3)
def line_interaction(x, female):
p = coef_3
return (
p["Intercept"]
+ p["beauty"] * x
+ p["female"] * female
+ p["beauty:female"] * x * female
)
fig, axes = plt.subplots(1, 2, figsize=(8, 3.5), sharex=True, sharey=True)
for ax, female, title, color in [(axes[0], 0, "Men", "tab:blue"), (axes[1], 1, "Women", "tab:red")]:
d = beauty[beauty.female == female]
ax.scatter(d["beauty"], d["eval"], alpha=0.75, color=color)
ax.plot(xs, line_parallel(xs, female), color="gray", linewidth=1, label="parallel")
ax.plot(xs, line_interaction(xs, female), color="black", linewidth=2, label="interaction")
ax.set_title(title)
ax.set_xlabel("Beauty")
axes[0].set_ylabel("Average teaching evaluation")
axes[0].legend(frameon=False)
```
## Additional controls
The original page fits a sequence of regressions with age, minority status, non-English indicator, lower-division course indicator, and course fixed effects. The same formulas can be passed directly to `lapylace`.
```{python}
formulas = {
"beauty + female + age": "eval ~ beauty + female + age",
"beauty + female + minority": "eval ~ beauty + female + minority",
"beauty + female + nonenglish": "eval ~ beauty + female + nonenglish",
"beauty + female + nonenglish + lower": "eval ~ beauty + female + nonenglish + lower",
"beauty + course indicators": "eval ~ beauty + C(course_id)",
}
rows = []
for i, (label, formula) in enumerate(formulas.items(), start=1):
m = fit_gaussian(formula, seed=16210 + i)
coefs = coef_medians(m)
ses = coef_sds(m)
rows.append({
"model": label,
"beauty_coef": coefs.get("beauty", np.nan),
"beauty_sd": ses.get("beauty", np.nan),
"n": len(beauty),
})
pd.DataFrame(rows)
```
## Stan path
```{python}
#| eval: false
fit_1.to_arviz()
fit_1.loo()
```