Fitting models to behavioral dataΒΆ
By Blair R. K. Shevlin, Ph.D.
Computational Psychiatry Trainee Pre-Conference 2026
This tutorial was inspired by and adapted from Shawn A. Rhoads' PSYC 347 Course [CC BY-SA 4.0 License], the examples from the pyEM Python package and the Neuromatch Academy tutorials [CC BY 4.0].
Who am I?ΒΆ
- PhD in Decision Psychology (The Ohio State University)
- Mentors: Ian Krajbich, Roger Ratcliff
- Focus: Value-based decision-making; sequential sampling models
- Post-doctoral training in Computational Psychiatry (Icahn School of Medicine)
- Mentors: Xiaosi Gu, Laura Berner
- Focus: Compulsive-use disorders; human voltammetry
- Currently: Instructor in the Department of Psychiatry (Icahn School of Medicine)
Goals of this tutorialΒΆ
We will fit one running model β a RescorlaβWagner reinforcement-learning model β three ways, where each method showews iterativel improvement:
- Maximum Likelihood Estimation (MLE): find the parameters that make the observed choices least surprising (minimize the negative log-likelihood).
- Watch MLE break: with few trials per subject, individual fits become noisy and hit the parameter boundaries.
- Maximum A Posteriori (MAP): add a prior to pull outlier estimates back toward something plausible β but who picks the prior?
- Hierarchical Expectation-Maximization (EM): let the group decide the prior and re-estimate everyone (partial pooling / shrinkage).
We use a short linear-regression example only as a warm-up for the likelihood idea, then do everything hierarchical on the RL model. The through-line is one parameter-recovery scatterplot, shown worse β better β best.
3 - 2 - 1 ExerciseΒΆ
For three minutes, list the following about estimating computational models:
- 3 things you know
- 2 things you would like to know
- 1 question you have
import numpy as np, pandas as pd, scipy, sys, os
import matplotlib.pyplot as plt, seaborn as sns
%config InlineBackend.figure_format = 'retina'
# Cache directory: slow fits are saved here so mid-talk re-runs are instant.
CACHE_DIR = "cache"
os.makedirs(CACHE_DIR, exist_ok=True)
Section 1 β Goals and NotesΒΆ
We are going to estimate the parameters of a cognitive model from behavior, and we will do it three ways. Before we start, one things to note about this tutorial.
A note on "hierarchical Bayes." This talk uses
pyEM, which does empirical Bayes via Expectation-Maximization (EM) β a close cousin of full hierarchical Bayes, not the same thing.
- Full hierarchical Bayes (e.g., Stan / MCMC) treats the group-level parameters as themselves uncertain and samples the entire joint posterior. You get full uncertainty at every level.
- EM (empirical Bayes) finds the single best group-level setting β a point estimate β and then fits each individual given it. It captures partial pooling / shrinkage, but it treats the group prior as known, which mildly understates uncertainty.
The one-liner to remember:
"EM finds the best group prior; full Bayes samples over all possible group priors."
EM is fast, robust, and gets you 90% of the shrinkage benefit β which is why it is so widely used in computational psychiatry.
Section 2 β The RescorlaβWagner model on a two-armed banditΒΆ
This is the single model we will fit for the rest of the talk. Everything downstream (MLE, MAP, EM) is just a different way of estimating its two parameters, Ξ± (learning rate) and Ξ² (inverse temperature).
A reinforcement-learning model (RescorlaβWagner)ΒΆ
Now let's fit a model to choice behavior - which option was selected at the end of each trial. We will use the RescorlaβWagner (RW) learning rule on a two-armed bandit: on each trial the agent picks one of two arms, gets a reward or not, and updates its value estimate for the arm it chose.
The core updating process is simple: nudge the value of the chosen arm toward the reward you just got, by a fraction Ξ±:
$$ Q_{t+1} = Q_t + \alpha \,\underbrace{(R_t - Q_t)}_{\text{prediction error}} $$
- $Q_t$: the agent's current value estimate for the chosen arm.
- $0 < \alpha < 1$ (learning rate): how big a step to take toward the reward. Large Ξ± = fast, forgetful learning; small Ξ± = slow, stable learning.
- $R_t - Q_t$ (prediction error): the surprise β how far the reward was from what we expected.
(Aside: this is the special case of the general temporal-difference rule $Q \leftarrow Q + \alpha[R + \gamma \max_a Q' - Q]$ with no future-state term, since a bandit has no state transitions. We use this form because it is the clearest way to see learning happen.)
To turn values into choices, we pass them through a softmax:
$$ P(a) = \frac{e^{\beta Q(a)}}{\sum_{a'} e^{\beta Q(a')}} $$
- $\beta$ (inverse temperature): how deterministically the agent exploits the higher-valued arm. High Ξ² = greedy; low Ξ² = random exploration.
So the two free parameters we will estimate are Ξ± (learning rate) and Ξ² (inverse temperature).
Two-Armed Bandit TaskΒΆ
A common setting for studying decision-making in reinforcement learning is the two-armed bandit task. In this task, the agent has to choose between two options (or arms), each of which provides a reward with a certain probability. One arm might provide a reward 80% of the time, while the other only 20% of the time. The agent has to learn which arm is more rewarding over time through exploration and exploitation.
One trial, start to finish. The simulator below runs exactly this loop for every trial:
- Values β probabilities. Feed the two current Q-values through the softmax (scaled by Ξ²) to get a choice probability for each arm.
- Choice. Sample an arm from that probability distribution.
- Reward. The chosen arm pays off with its reward probability (here arm A pays 80%, arm B 20%).
- Prediction error. Compute $R_t - Q_t$ for the chosen arm β the surprise.
- Update. Nudge that arm's Q-value by $\alpha \times$ prediction error.
We initialize both Q-values at 0.5 (a neutral "coin-flip" prior belief). The simulator takes a seed so the exact same choices and rewards are reproduced every run β this is what lets us cache slow fits and still match what we rehearsed.
def softmax(values, beta=1.0):
"""Turn expected values into choice probabilities.
Bigger `beta` -> more deterministic choices (pick the best option almost every time).
Smaller `beta` -> more random choices (choice probabilities closer to 50/50).
"""
scaled = values * beta
return np.exp(scaled) / np.sum(np.exp(scaled))
def rw_update(current_value, reward, alpha):
"""One Rescorla-Wagner update step: nudge a value toward the reward.
This is the one piece of math shared by rw1a1b_sim (simulating an
agent) and rw1a1b_fit (asking how likely a real agent's choices were
under some alpha/beta) -- same rule, used in two directions.
"""
prediction_error = reward - current_value
updated_value = current_value + alpha * prediction_error
return updated_value, prediction_error
def rw1a1b_sim(params: np.ndarray, nblocks: int = 3, ntrials: int = 24,
outcomes: np.ndarray | None = None, seed: int | None = 42):
"""Simulate a simple Rescorla-Wagner (RW) reinforcement-learning model.
THE TASK
--------
On every trial, a simulated subject picks between two options, "A" and
"B". Option A pays a reward 80% of the time; option B pays a reward 20%
of the time. After choosing, the subject updates their belief about how
good the *chosen* option is (its "expected value"), using the
Rescorla-Wagner learning rule:
new_value = old_value + alpha * (reward - old_value)
- `alpha` (learning rate) controls how much the subject updates after
each outcome.
- `beta` (inverse temperature) controls how deterministically the
subject picks the option they currently think is best (see softmax
above).
PARAMETERS
----------
params : np.ndarray, shape (n_subjects, 2)
Column 0 = beta (inverse temperature; must be in [1e-5, 20])
Column 1 = alpha (learning rate; must be in [0, 1])
nblocks : int
Number of separate task blocks to simulate per subject.
ntrials : int
Number of trials per block.
outcomes : np.ndarray or None, shape (nblocks, ntrials, 2)
If given, these fixed rewards (0.0/1.0) are used instead of drawing
rewards randomly. Handy when you want every simulated subject to
see the *same* sequence of outcomes (e.g. real experiment data, or
a fair comparison between subjects).
seed : int or np.random.Generator
Makes the simulation reproducible. Pass a fixed seed (or a
pre-built Generator) to get identical choices/rewards every run.
RETURNS
-------
A dictionary of arrays (each shaped (n_subjects, nblocks, ntrials[, 2])):
"choices" : "A" or "B" chosen on each trial
"choices_A" : 1.0 if A was chosen, else 0.0
"rewards" : reward received (0.0 or 1.0)
"EV" : learned value of each option (index 0=A, 1=B).
One extra trial long: it also stores the value
*after* the final update.
"ch_prob" : model's choice probabilities [P(A), P(B)]
"PE" : prediction error (reward - expected value) that
drove each value update
"nll" : negative log-likelihood of the choice actually made
(used later for model fitting)
"params" : the (beta, alpha) used for each subject
"""
# Reward probability for each option, as [P(reward=1), P(reward=0)].
# Row 0 = option A (80% rewarded), row 1 = option B (20% rewarded).
REWARD_PROBS_BY_OPTION = np.array([
[0.8, 0.2], # option A
[0.2, 0.8], # option B
])
# Accept either a plain integer seed or an already-built Generator, so
# callers can share one RNG across multiple calls if they want to.
rng = seed if isinstance(seed, np.random.Generator) else np.random.default_rng(seed)
n_subjects = params.shape[0]
all_beta = params[:, 0]
all_alpha = params[:, 1]
# --- sanity-check the parameters -------------------------------------
if not ((all_beta >= 1e-5) & (all_beta <= 20.0)).all():
raise ValueError("Beta values out of bounds (must be in [1e-5, 20])")
if not ((all_alpha >= 0.0) & (all_alpha <= 1.0)).all():
raise ValueError("Alpha values out of bounds (must be in [0, 1])")
# --- pre-allocate storage for everything we want to record ----------
choices = np.empty((n_subjects, nblocks, ntrials), dtype=object) # "A" or "B"
choices_A = np.zeros((n_subjects, nblocks, ntrials)) # 1.0 / 0.0
rewards = np.zeros((n_subjects, nblocks, ntrials)) # 1.0 / 0.0
ch_prob = np.zeros((n_subjects, nblocks, ntrials, 2)) # [P(A), P(B)]
PE = np.zeros((n_subjects, nblocks, ntrials)) # prediction error
nll = np.zeros((n_subjects, nblocks, ntrials)) # -log P(choice made)
# EV ("expected value") has one *extra* trial slot per block: it stores
# the value *before* trial 0, and *after* every trial's update.
# Both options start at 0.5 (fully uncertain), matching pyEM convention.
EV = np.full((n_subjects, nblocks, ntrials + 1, 2), 0.5)
# --- run the simulation ----------------------------------------------
for subject_i in range(n_subjects):
beta = float(all_beta[subject_i])
alpha = float(all_alpha[subject_i])
for block_i in range(nblocks):
EV[subject_i, block_i, 0, :] = 0.5 # reset values at the start of each block
for trial_i in range(ntrials):
current_values = EV[subject_i, block_i, trial_i, :]
# 1) Turn current values into choice probabilities.
choice_probs = softmax(current_values, beta)
ch_prob[subject_i, block_i, trial_i, :] = choice_probs
# 2) Sample a choice: 0 = A, 1 = B.
choice = rng.choice([0, 1], p=choice_probs)
choices[subject_i, block_i, trial_i] = "A" if choice == 0 else "B"
choices_A[subject_i, block_i, trial_i] = 1.0 if choice == 0 else 0.0
# 3) Get the reward for the chosen option this trial.
if outcomes is None:
# No fixed schedule given -> draw a reward randomly
# using that option's true reward probability.
reward_probs = REWARD_PROBS_BY_OPTION[choice]
reward = rng.choice([1.0, 0.0], p=reward_probs)
else:
# A fixed outcome schedule was given -> just look it up
# instead of drawing randomly.
reward = float(outcomes[block_i, trial_i, choice])
rewards[subject_i, block_i, trial_i] = reward
# 4) Rescorla-Wagner update: compute the prediction error
# and nudge the chosen option's value toward the reward.
# The unchosen option's value is carried over unchanged.
new_value, prediction_error = rw_update(current_values[choice], reward, alpha)
PE[subject_i, block_i, trial_i] = prediction_error
EV[subject_i, block_i, trial_i + 1, :] = current_values
EV[subject_i, block_i, trial_i + 1, choice] = new_value # current_value + alpha * prediction_error
# 5) Record how "surprised" the model is by the choice it
# made -- used later when fitting the model to data.
nll[subject_i, block_i, trial_i] = -np.log(choice_probs[choice] + 1e-12)
return {
"params": np.array([all_beta, all_alpha]).T,
"choices": choices,
"rewards": rewards,
"EV": EV,
"ch_prob": ch_prob,
"choices_A": choices_A,
"PE": PE,
"nll": nll,
}
# Quick reproducibility check: same seed -> identical simulated data.
_a = rw1a1b_sim(np.array([[3.0, 0.4]]), nblocks=1, ntrials=20, seed=42)
_b = rw1a1b_sim(np.array([[3.0, 0.4]]), nblocks=1, ntrials=20, seed=42)
assert np.array_equal(_a["choices"], _b["choices"]) and np.array_equal(_a["rewards"], _b["rewards"])
print("Seeded simulator is reproducible:", True)
Seeded simulator is reproducible: True
from scipy.stats import truncnorm, beta as beta_dist
# simulate computer agents completing the two-armed task
nsubjects, nblocks, ntrials = 80, 1, 100
betamin, betamax = .75, 10 # inverse temperature
alphamin, alphamax = .05, .95 # learning rate
# generate distribution of parameters within range
np.random.seed(42)
beta_rv = truncnorm((betamin-0)/1, (betamax-0)/1, loc=.2, scale=2.2).rvs(nsubjects)
a_lo, a_hi = beta_dist.cdf([alphamin, alphamax], 1.1, 1.1)
alpha_rv = beta_dist.ppf(a_lo + np.random.rand(nsubjects)*(a_hi - a_lo), 1.1, 1.1)
rl_params = np.column_stack((beta_rv, alpha_rv))
rl_param_names = ['beta', 'alpha']
sim_output = rw1a1b_sim(rl_params, nblocks=1, ntrials=ntrials)
# plot distributions of params from all agents
fig, ax = plt.subplots(1, 2, figsize=(8, 4))
for i in range(len(rl_param_names)):
sns.histplot(rl_params[:,i], kde=True, ax=ax[i])
ax[i].set_title(rl_param_names[i])
plt.tight_layout()
plt.show()
I'll now show you the behavior of two different agents: one with a low learning rate (Ξ± = 0.05) and one with a high learning rate (Ξ± = 0.8). Both face the same task (arm A pays 80%, arm B 20%) with the same inverse temperature (Ξ² = 2.56) and the same random seed. The only thing that differs between them is Ξ±.
The plot below shows, for each agent, the reward received on every trial (blue dots, 1 or 0) and which arm was chosen (red dashed line). As you look at it, ask yourself: does the learning rate change what the agent eventually settles on, or how it gets there?
Keep in mind that this is the observable side of the experiment β the choices and rewards are all we'd actually record from a real subject. In the next plot we'll lift the hood and look at the hidden Q-values driving these choices, which is where the effect of Ξ± becomes much clearer.
A caveat to keep in mind: the reward dots are not a fixed schedule held constant across the two agents. Each dot is the outcome of whichever arm that agent actually chose, so once their choices diverge, they're drawing from different outcomes. Any single-subject difference here (e.g. how often each switches arms) is dependent on chance outcomes β so treat this as one illustrative run, not a general claim about Ξ±!
# Generate two subjects: SAME beta, SAME seed (same task), only alpha differs ---
ntrials = 100
beta = 2.56
low_alpha, high_alpha = 0.05, 0.8
# Same seed => identical reward schedule, so the ONLY difference is the learning rate.
low = rw1a1b_sim(np.array([[beta, low_alpha]]), ntrials=ntrials, seed=5)
high = rw1a1b_sim(np.array([[beta, high_alpha]]), ntrials=ntrials, seed=5)
# plot actions and rewards over trials for these agents
fig, axes = plt.subplots(1, 2, figsize=(12, 4), sharey=True)
for ax, out, a, ttl in [
(axes[0], low, low_alpha, f'Low learning rate (Ξ± = {low_alpha})'),
(axes[1], high, high_alpha, f'High learning rate (Ξ± = {high_alpha})'),
]:
# Reward: 1 / 0 per trial (blue dots)
ax.scatter(range(ntrials), out['rewards'][0, 0, :],
label='Reward', color='blue', alpha=.3)
ax.set_xlabel('Trials')
ax.set_ylim(-0.1, 1.1)
ax.set_title(f'{ttl}\nchose the 80% arm {int(out["choices_A"][0,0,:].sum())}/{ntrials} trials')
# Choice on a twin axis (red dashed line): 1 = chose arm A (80%), 0 = arm B (20%)
ax2 = ax.twinx()
ax2.plot(range(ntrials), out['choices_A'][0, 0, :].astype(int),
label='Choice', color='red', linestyle='--')
ax2.set_ylim(-0.1, 1.1)
ax2.set_yticks([0, 1])
ax2.set_yticklabels(['Arm B (20%)', 'Arm A (80%)'])
axes[0].set_ylabel('Rewards (Blue)', color='blue')
# only label the right-hand choice axis on the rightmost panel to avoid clutter
axes[1].figure.axes[-1].set_ylabel('Choices (Red)', color='red', rotation=270, labelpad=28)
fig.suptitle(r'Choices & rewards β same task, same $\beta$, only $\alpha$ differs', fontsize=12)
plt.tight_layout()
plt.savefig('choices_rewards_low_vs_high.png', dpi=150, bbox_inches='tight')
We just looked at the observable behavior β the choices and rewards. Now let's look at what's driving those choices: the agent's internal Q-values which represent the running estimate of how good each arm is.
The plot below shows, for the same two agents, how $Q_1$ (the 80% arm) and $Q_2$ (the 20% arm) evolve over trials. This is where the effect of Ξ± becomes more clear. Recall the update rule:
$$ Q_{t+1} = Q_t + \alpha\,(R_t - Q_t) $$
Ξ± controls how much each outcome changes the estimate. After a single unrewarded trial, the value shrinks to $(1-\alpha)\,Q_t$ β so a high Ξ± β 0.8 means one bad outcome nearly wipes the estimate out, while a low Ξ± β 0.05 barely nudges it.
# also plot Q-values over trials
fig, axes = plt.subplots(1, 2, figsize=(11, 4), sharey=True)
for ax, out, a, ttl in [
(axes[0], low, low_alpha, f'Low learning rate (Ξ± = {low_alpha})'),
(axes[1], high, high_alpha, f'High learning rate (Ξ± = {high_alpha})'),
]:
ax.plot(range(ntrials), out['EV'][0, 0, 1:, 0], label=r'$Q_{1}$ (80% arm)', color='blue')
ax.plot(range(ntrials), out['EV'][0, 0, 1:, 1], label=r'$Q_{2}$ (20% arm)',
color='red', linestyle='--')
ax.axhline(0.8, color='0.6', lw=1, ls=':', label='true reward rate (0.8)')
ax.set_xlabel('Trials')
ax.set_title(ttl)
sns.despine(ax=ax)
axes[0].set_ylabel('Q-Value')
axes[0].legend(loc='center right', fontsize=8, frameon=False)
fig.suptitle(r'Same task, same $\beta$ β only $\alpha$ differs', fontsize=12)
plt.tight_layout()
plt.savefig('low_vs_high_alpha.png', dpi=150, bbox_inches='tight')
print("Low-alpha Q1: min=%.3f max=%.3f std=%.3f" %
(low['EV'][0,0,1:,0].min(), low['EV'][0,0,1:,0].max(), low['EV'][0,0,1:,0].std()))
print("High-alpha Q1: min=%.3f max=%.3f std=%.3f" %
(high['EV'][0,0,1:,0].min(), high['EV'][0,0,1:,0].max(), high['EV'][0,0,1:,0].std()))
Low-alpha Q1: min=0.500 max=0.826 std=0.093 High-alpha Q1: min=0.008 max=1.000 std=0.408
Things to notice:
- How jagged each $Q_1$ trace is. The high-Ξ± agent's value spikes and crashes trial-to-trial; the low-Ξ± agent's climbs smoothly and settles near the true reward rate (0.8). The amplitude of those swings is the learning rate made visible.
- What happens to $Q_2$. Only the chosen arm gets updated, so an arm that stops being picked has its value frozen in place.
Unlike the behavior plot, this comparison is clear: same task, same Ξ², yet Q-values are a deterministic function of the choices and rewards. In other words, every difference you see is attributable to Ξ± alone.
Muddiest Point (3 minutes)ΒΆ
With someone seated next to you, discuss either the most important take-away or the most confusing concept. Try to help each other understand the content!
Section 3 β Likelihood & MLEΒΆ
Likelihood = "how surprising is the data under these parameters?" The negative log-likelihood (NLL) is our surprise score: lower is better. MLE adjusts the parameter values (Ξ±, Ξ²) until the observed choices are least surprising. We'll demonstrate this principle with a linear regression, then show how it works with the RL model.
What is Maximum Likelihood Estimation (MLE)?ΒΆ
Maximum Likelihood Estimation (MLE) is a fundamental method used to estimate the parameters of a model by finding the parameter values that make the observed data most probable. This concept is widely used across various fields to fit models to behavioral and cognitive data.
In simple terms, MLE attempts to answer the question: Given the data we have observed, what are the parameter values that maximize the probability of observing this data?
In MLE, we assume a specific form for the probability distribution of the data and adjust the model's parameters until we find the highest likelihood.
Key ConceptsΒΆ
Given a set of observed data points, MLE aims to find the values of the parameters, denoted by ($\theta$), that maximize the likelihood function:
$$ \mathcal{L}(\theta) = P(\mathcal{D} | \theta) $$
Here:
- $\mathcal{D}$ represents the observed data.
- $\theta$ represents the set of parameters of the model.
The likelihood function, $\mathcal{L}(\theta)$, expresses how likely the observed data is for different parameter values. MLE finds the parameter values $\theta^*$ that maximizes this likelihood function, which can also be equivalently done by maximizing the log-likelihood, as it is often easier to work with:
$$ \theta^* = \text{argmax}_{\theta} \; \log \mathcal{L}(\theta) $$
Instead of working directly with the likelihood function, it is often easier to work with the logarithm of the likelihood, called the log-likelihood. The log-likelihood is more computationally convenient, especially when dealing with products of probabilities.
MLE requires finding the parameter values that maximize the likelihood or log-likelihood. This is an optimization problem and can be solved using various methods, such as grid search or iterative optimization algorithms.
Example 2: Linear Regression ModelΒΆ
In the first example, we'll discuss fitting a multivariate linear regression model using MLE. I like to start with this example because it's a simple and intuitive model that many people are familiar with.
A multivariate linear regression model extends simple linear regression to multiple predictors. Given a set of observed data points, our goal is to estimate the relationship between the predictors and the outcome variable. Mathematically, the model can be represented as:
$$ Y = X\beta + \epsilon $$
Here:
- $Y$ is the vector of observed outcome values with shape $(n, 1)$, where $n$ is the number of observations.
- $X$ is the matrix of predictor variables with shape $(n, p)$, where $n$ is the number of observations and $p$ is the number of predictors.
- $\beta$ is the vector of regression coefficients (parameters we wish to estimate) with shape $(p, 1)$.
- $\epsilon$ represents the error term, which is typically assumed to be normally distributed with mean zero and variance $\sigma^2$.
We can also write the expanded form of the model for each observation $i$ as:
$$ y_i = \beta_0 + \beta_1 x_{i1} + \beta_2 x_{i2} + \ldots + \beta_p x_{ip} + \epsilon_i $$
# Let's generate some data based on a simple linear model
np.random.seed(42)
n = 100
x1 = np.random.uniform(0, 10, n)
x2 = np.random.uniform(0, 10, n)
noise = np.random.normal(0, 1, n)
b0 = 2.41 # intercept
b1 = 1.64 # slope 1
b2 = -3.27 # slope 2
y = b0 + b1*x1 + b2*x2 + noise
# Let's plot the data
fig, ax = plt.subplots()
ax.scatter(x1, y, label='x1')
ax.scatter(x2, y, label='x2')
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.legend(loc='lower center')
sns.despine()
plt.title('Simulated Data')
plt.show()
Now we can try to estimate the free parameters: b0, b1, and b2. To understand how we estimate the parameters of the model, we need to describe the probability distribution of the errors.
The Gaussian distribution is a common choice for modeling the errors (residuals) in a regression model. This distribution assumes that the errors are normally distributed, meaning that they are symmetrically distributed around zero, and most of the error values are close to zero with fewer extreme values.
The probability density function (PDF) for the observed outcomes, assuming normally distributed errors, is given by:
$$ P(\mathcal{D} | \beta) = \prod_{i=1}^n \frac{1}{\sqrt{2\pi\sigma^2}} \exp\left( -\frac{(y_i - X_i \beta)^2}{2\sigma^2} \right) $$
Let's break this down:
- The term $\frac{1}{\sqrt{2\pi\sigma^2}}$ represents the normalization factor of the Gaussian distribution, which ensures that the total area under the curve is equal to 1.
- The exponential term $\exp\left( -\frac{(y_i - X_i \beta)^2}{2\sigma^2} \right)$ represents how the likelihood of each observation decreases as the difference between the predicted value ($X_i \beta$) and the actual value ($y_i$) increases. This term captures how well the model fits the data.
Where $y_i$ represents the $i$-th observed value and $X_i$ represents the corresponding row of the predictor matrix. The goal of MLE is to find the parameters $\beta$ that maximize this likelihood function, which means finding the values of $\beta$ that make the observed data most probable.
In practice, we usually maximize the log-likelihood rather than the likelihood itself. Because the log turns products into sums, it converts the product over observations into a sum of log-likelihoods, which are easier to compute, differentiate, and interpret. It also has the added benefit of avoiding the numerical underflow you get when multiplying many small probabilities.
Additionally, instead of maximizing the log-likelihood, we can equivalently minimize the negative log-likelihood. This approach is often used because many optimization libraries are designed to minimize functions by default, making it convenient to use negative log-likelihood as our "objective function."
***Note*:** Wait - but can't we just use the analytic solution for linear regression? Yes, for linear regression we can easily derive an analytic solution for $\beta$. However, we are starting with a linear model to demonstrate how MLE can be applied to a simple model, as the same approach generalizes to more complex models where an analytic solution is not available.
# let's create a function to calculate the negative log-likelihood - this is often called an objective function
def negll_lm(params, y, x1, x2):
b0, b1, b2 = params
y_pred = b0 + b1*x1 + b2*x2
likelihoods = scipy.stats.norm.logpdf(y, y_pred)
return -np.sum(likelihoods)
Algorithms for Maximizing LikelihoodΒΆ
To estimate the model parameters using MLE, we often use optimization algorithms. Some common approaches include:
- Grid Search: This involves evaluating the likelihood at a grid of parameter values and selecting the one with the highest value. This is straightforward but computationally expensive for complex models or high-dimensional parameter spaces.
- Gradient-Based Optimization: Algorithms like BFGS are commonly used to find the parameters that maximize the likelihood. These methods iteratively adjust the parameters based on the gradient of the likelihood function, converging to the optimal values. In Python, the
scipy.optimize.minimizefunction is often used for this purpose, with BFGS being the default method for unconstrained problems. This function provides a flexible way to optimize the negative log-likelihood function, allowing us to effectively maximize the likelihood of the observed data given the model parameters.
In the next sections, we will explore how to apply MLE to fit different models, starting with a simple linear regression model and then moving on to a reinforcement learning model. We will use both grid search and gradient-based optimization approaches to illustrate the versatility of MLE.
# we can use scipy to minimize the negative log-likelihood
initial_guess = [0, 0, 0]
result = scipy.optimize.minimize(negll_lm,
initial_guess,
args=(y, x1, x2))
b0_hat, b1_hat, b2_hat = result.x
# grid search: try many combinations of b0, b1, b2 and keep the best (lowest negative log-likelihood)
b0_grid = np.linspace(-5, 5, 20)
b1_grid = np.linspace(-5, 5, 20)
b2_grid = np.linspace(-5, 5, 20)
best_negll = np.inf
best_params = None
for b0_ in b0_grid:
for b1_ in b1_grid:
for b2_ in b2_grid:
negll = negll_lm([b0_, b1_, b2_], y, x1, x2)
if negll < best_negll:
best_negll = negll
best_params = (b0_, b1_, b2_)
b0_hat_grid, b1_hat_grid, b2_hat_grid = best_params
print(f"Grid search evaluated {len(b0_grid)*len(b1_grid)*len(b2_grid)} combinations; "
f"BFGS took only {result.nfev} evaluations.")
Grid search evaluated 8000 combinations; BFGS took only 28 evaluations.
# let's print the estimated parameters from the optimization
print(f'Parameter | Actual | Estimated (BFGS) | Estimated (Grid)')
print(f'b0 | {b0:.2f} | {b0_hat:.2f} | {b0_hat_grid:.2f}')
print(f'b1 | {b1:.2f} | {b1_hat:.2f} | {b1_hat_grid:.2f}')
print(f'b2 | {b2:.2f} | {b2_hat:.2f} | {b2_hat_grid:.2f}')
Parameter | Actual | Estimated (BFGS) | Estimated (Grid) b0 | 2.41 | 2.32 | 2.37 b1 | 1.64 | 1.61 | 1.84 b2 | -3.27 | -3.20 | -3.42
# we can also plot y as a function of x2 (line) with the simulated data points (scatter)
fig, ax = plt.subplots()
y_hat_bf = b0_hat + b1_hat*np.mean(x1) + b2_hat*x2
y_hat_grid = b0_hat_grid + b1_hat_grid*np.mean(x1) + b2_hat_grid*x2
# plot fitted line
ax.plot(x2, y_hat_bf, label='Predicted Data (BFGS)', color='black')
ax.plot(x2, y_hat_grid, label='Predicted Data (Grid)', color='red', linestyle='--')
ax.scatter(x2, y, label='True Simulated Data', alpha=0.5, s=50)
ax.set_xlabel('x2')
ax.set_ylabel('y')
ax.legend(loc='lower center')
sns.despine()
plt.show()
Now, we have estimated the parameters of a linear regression model using both a brute force grid search and the scipy.optimize.minimize function. Notice that the estimated parameters from both methods did not exactly match the actual parameters used to generate the data. This discrepancy can be attributed to noise in the data and the limitations of the optimization methods. (Also remember that all models just approximate the true observed data, so we should never expect the estimated parameters to perfectly match the true parameters.)
Additionally, the optimization performed by scipy generally did a better job compared to the brute force grid search. This is because scipy.optimize.minimize uses more sophisticated algorithms like BFGS that can efficiently navigate the parameter space by leveraging gradient information, whereas grid search exhaustively evaluates a fixed grid of values, which is computationally expensive and less precise.
Estimating the RL parameters with MLEΒΆ
Same idea as the linear-regression: define a surprise score (negative log-likelihood) and ajdust the Ξ±, Ξ² values to minimize it. We visualize the 2-D surprise surface with a grid search once, then use scipy for the real fit.
Estimating Parameters with MLEΒΆ
Now we can try to accurately estimate the free parameters: $\alpha$ and $\beta$ that best explain a set of observed choices and rewards. Similar to the linear regression example, we will use both grid search and scipy.optimize.minimize to find the parameter values that maximize the likelihood of the observed data.
The likelihood of the observed data depends on both the value updates and the action choices made by the agent. We will use the negative log-likelihood as the objective function to minimize.
The negative log-likelihood function for the TD Learning model can be expressed as:
$$ -\log \mathcal{L}(\beta, \alpha, \gamma) = -\sum_{t=1}^{T} \log P(A_t | \beta, \alpha) $$
where $P(A_t | \beta, \alpha)$ is the probability of selecting action $A_t$ given the model parameters $\beta, \alpha$.
Reparameterization (why
norm2alpha/norm2beta?). Ξ± must live in (0, 1) and Ξ² in (0, 20), but optimizers likescipy.minimizewant to roam freely over the whole real line. So we fit in an unconstrained space and squash the values back into legal ranges with a logit (for Ξ±) / scaled-logistic (for Ξ²) transform. The optimizer never proposes an impossible learning rate, and we never have to write awkward bound constraints.
# Fit function for a single subject. Here we compute the PURE negative
# log-likelihood (our "surprise" score). The prior term is added later, in the
# MAP / EM sections -- MLE uses likelihood alone.
def norm2alpha(x):
return scipy.special.expit(np.asarray(x)) # R -> (0, 1)
def norm2beta(x, max_val=20.0):
return max_val / (1.0 + np.exp(-np.asarray(x))) # R -> (0, max_val)
def calc_fval(negll, params, prior=None, output='npl'):
"""Objective value. With a prior, returns -log[P(data|h) * P(h)] (MAP);
otherwise returns the plain negative log-likelihood (MLE)."""
if output == 'npl' and prior is not None and hasattr(prior, 'logpdf'):
fval = -(-negll + prior.logpdf(np.asarray(params)))
if np.isinf(fval):
fval = 1e7 # keep gradient-based optimizers moving
return fval
else:
return negll
def rw1a1b_fit(params, choices, rewards, prior=None, output="npl"):
"""Evaluate a Rescorla-Wagner model against a subject's real choices/rewards.
This runs the *same* learning rule as rw1a1b_sim (see rw_update), but
instead of sampling a choice, it replays the choices/rewards a subject
actually produced and asks: "how likely was this behavior, given this
alpha/beta?" That likelihood (as a negative log-likelihood, `nll`) is
what an optimizer searches over to find the alpha/beta that best
explain the subject's data.
`params` come from the optimizer on an *unconstrained* scale, so we
map them into valid ranges with norm2beta/norm2alpha before using them
(keeps beta > 0 and alpha in [0, 1] without the optimizer needing to
respect those bounds itself).
"""
beta = float(norm2beta(params[0]))
alpha = float(norm2alpha(params[1]))
# Reject impossible parameter values with a large penalty instead of
# raising an error -- the optimizer needs a *number* to keep searching.
if not (1e-5 <= beta <= 20.0):
return 1e7
if not (0.0 <= alpha <= 1.0):
return 1e7
nblocks, ntrials = rewards.shape
EV = np.full((nblocks, ntrials + 1, 2), 0.5) # init Q = 0.5, same as rw1a1b_sim
PE = np.zeros((nblocks, ntrials))
nll = 0.0
for block_i in range(nblocks):
EV[block_i, 0, :] = 0.5
for trial_i in range(ntrials):
# This subject's *actual* choice and reward this trial --
# nothing is sampled here, we're just replaying what happened.
choice = 0 if choices[block_i, trial_i] == "A" else 1
reward = rewards[block_i, trial_i]
choice_probs = softmax(EV[block_i, trial_i, :], beta)
new_value, prediction_error = rw_update(
EV[block_i, trial_i, choice], reward, alpha
)
PE[block_i, trial_i] = prediction_error
EV[block_i, trial_i + 1, :] = EV[block_i, trial_i, :]
EV[block_i, trial_i + 1, choice] = new_value
# How surprised is the model by the choice this subject actually made?
nll += -np.log(choice_probs[choice] + 1e-12)
if output == "all":
choices_A = (np.asarray(choices) == "A").astype(float)
return {
"params": [beta, alpha], "choices": choices, "choices_A": choices_A,
"rewards": rewards, "EV": EV, "PE": PE, "nll": nll,
}
return calc_fval(nll, params, prior=prior, output=output)
# use the TD-learning model to fit the simulated data with a grid search
beta_grid_norm = np.linspace(-5, -1, 50)
beta_grid = norm2beta(beta_grid_norm)
alpha_grid_norm = np.linspace(-2, 3, 50)
alpha_grid = norm2alpha(alpha_grid_norm)
nll = np.zeros((50, 50))
for i in range(50):
for j in range(50):
nll[i, j] = rw1a1b_fit([beta_grid_norm[i], alpha_grid_norm[j]], sim_output['choices'][0,:,:], sim_output['rewards'][0,:,:], prior=None, output="nll")
# find the optimal parameters
i_opt, j_opt = np.unravel_index(nll.argmin(), nll.shape)
beta_opt = beta_grid[i_opt]
alpha_opt = alpha_grid[j_opt]
# plot the negative log-likelihood as a heatmap
fig, ax = plt.subplots()
sns.heatmap(nll, ax=ax, cmap='Spectral')
# Label axes
ax.set_ylabel(r'$\beta$')
ax.set_xlabel(r'$\alpha$')
# Choose a manageable number of tick labels
num_ticks = 6
beta_tick_indices = np.linspace(0, len(beta_grid) - 1, num_ticks).astype(int)
alpha_tick_indices = np.linspace(0, len(alpha_grid) - 1, num_ticks).astype(int)
# Position ticks at cell centers
ax.set_yticks(beta_tick_indices + 0.5)
ax.set_xticks(alpha_tick_indices + 0.5)
# Apply the correct value labels
ax.set_yticklabels([f"{beta_grid[i]:.2f}" for i in beta_tick_indices])
ax.set_xticklabels([f"{alpha_grid[j]:.2f}" for j in alpha_tick_indices])
# Ensure Ξ² increases upward, not downward
ax.invert_yaxis()
# mark the optimal parameters
ax.scatter(j_opt, i_opt, color='black', marker='x', s=100)
ax.collections[0].colorbar.set_label('Negative Log-Likelihood (lower = better)', rotation=270, labelpad=20)
plt.show()
# print the estimated parameters from the grid search
print(f'Parameter | Actual | Estimated')
print(f'beta | {sim_output["params"][0,0]:.2f} | {beta_opt:.2f}')
print(f'alpha | {sim_output["params"][0,1]:.2f} | {alpha_opt:.2f}')
Parameter | Actual | Estimated beta | 2.56 | 2.80 alpha | 0.82 | 0.72
# now let's use scipy to minimize the negative log-likelihood
initial_guess = [.2, 1]
result = scipy.optimize.minimize(rw1a1b_fit, initial_guess, args=(sim_output['choices'][0,:,:], sim_output['rewards'][0,:,:], None, 'nll'))
beta_hat, alpha_hat = norm2beta(result.x[0]), norm2alpha(result.x[1])
# print the estimated parameters from the optimization
print(f'Parameter | Actual | Estimated')
print(f'beta | {sim_output["params"][0,0]:.2f} | {beta_hat:.2f}')
print(f'alpha | {sim_output["params"][0,1]:.2f} | {alpha_hat:.2f}')
Parameter | Actual | Estimated beta | 2.56 | 2.89 alpha | 0.82 | 0.73
Discuss (3 minutes)ΒΆ
With the person next to you:
- what are the differences between grid search and gradient-based (BFGS) optimization?
- what, if anything, are you having trouble understanding about MLE?
Section 4 β Where MLE breaksΒΆ
Fitting each subject independently works when every subject has lots of trials. But real experiments are often short. Watch the recovery scatter plot fall apart when we only have a handful of trials per subject: estimates get noisy and pile up against the boundaries (Ξ± β 0 or 1, Ξ² β max).
# Fit a SEPARATE MLE model to each agent independently (no pooling).
# Cached: slow to re-run live, so we save/load. The seeded sim guarantees
# the cache matches a live re-fit.
_mle_cache = os.path.join(CACHE_DIR, "mle_indep_params.npy")
if os.path.exists(_mle_cache):
rl_est_params = np.load(_mle_cache)
print("Loaded cached independent-MLE estimates.")
else:
rl_est_params = np.zeros((nsubjects, 2))
rng_init = np.random.default_rng(0)
for simS in range(nsubjects):
initial_guess = rng_init.normal(-1, 1, 2)
result = scipy.optimize.minimize(
rw1a1b_fit, initial_guess,
args=(sim_output['choices'][simS, :, :], sim_output['rewards'][simS, :, :], None, 'nll'))
rl_est_params[simS, :] = norm2beta(result.x[0]), norm2alpha(result.x[1])
np.save(_mle_cache, rl_est_params)
print("Fit and cached independent-MLE estimates.")
Loaded cached independent-MLE estimates.
# Recovery scatter for independent MLE. This is panel 1 of the running
# "worse -> better -> best" comparison.
from pyem.utils import plotting
fig, axes = plt.subplots(1, 2, figsize=(8, 4))
for i, name in enumerate(rl_param_names):
plotting.plot_scatter(rl_params[:, i], f'Simulated {name}',
rl_est_params[:, i], f'Estimated {name}',
ax=axes[i])
fig.suptitle('MLE (independent per-subject fits)')
plt.tight_layout()
plt.show()
The single-subject fits are okay when each agent has lots of trials. But MLE fits every subject in isolation, with nothing to lean on β so with realistic amounts of data the estimates get noisy and unreliable. Let's make that failure visible.
# Where MLE breaks: with few trials, independent MLE estimates get noisy and
# pile up on the boundaries. We PRE-RENDER this figure once (seeded, offline)
# and load the PNG so the talk has no live re-simulation here.
_ss_png = os.path.join("figures", "mle_smallsample.png")
os.makedirs("figures", exist_ok=True)
if not os.path.exists(_ss_png):
ss_ntrials = 25 # short experiment
ss_sim = rw1a1b_sim(rl_params, nblocks=1, ntrials=ss_ntrials, seed=123)
ss_est = np.zeros((nsubjects, 2))
_rng = np.random.default_rng(7)
for s in range(nsubjects):
ig = _rng.normal(-1, 1, 2)
r = scipy.optimize.minimize(
rw1a1b_fit, ig,
args=(ss_sim['choices'][s, :, :], ss_sim['rewards'][s, :, :], None, 'nll'))
ss_est[s, :] = norm2beta(r.x[0]), norm2alpha(r.x[1])
figss, axss = plt.subplots(1, 2, figsize=(8, 4))
for i, name in enumerate(rl_param_names):
plotting.plot_scatter(rl_params[:, i], f'Simulated {name}',
ss_est[:, i], f'Estimated {name}',
ax=axss[i], colorname='firebrick')
figss.suptitle(f'MLE with only {ss_ntrials} trials/subject β recovery fails')
figss.tight_layout()
figss.savefig(_ss_png, dpi=150, bbox_inches='tight')
plt.close(figss)
from IPython.display import Image, display
display(Image(filename=_ss_png))
Section 5 β Priors & the MAPΒΆ
The fix for issues with MLE estimates: add a prior that says "extreme parameter values are implausible." Maximum A Posteriori (MAP) estimation maximizes likelihood Γ prior instead of likelihood alone. The prior acts like a rubber band, pulling outlier estimates back toward something reasonable.
What is Maximum A Posteriori (MAP) Estimation?ΒΆ
MAP estimation is the Bayesian cousin of MLE. Where MLE maximizes the likelihood alone, MAP multiplies the likelihood by a prior $P(\theta)$ that encodes what parameter values are plausible before seeing the data:
$$ \theta^{*} = \arg\max_{\theta}\; \underbrace{P(\mathcal{D}\mid\theta)}_{\text{likelihood}} \; \cdot \; \underbrace{P(\theta)}_{\text{prior}} $$
The prior acts as a regularizer: it penalizes implausible values (like Ξ± β 0.999 or Ξ² at the ceiling) so a few noisy trials can't drag an estimate to the boundary. In log space, MAP just adds a prior term to our surprise score β which is exactly the prior argument already wired into rw1a1b_fit.
# Standalone MAP fit: re-fit the SAME subjects, but now with a FIXED prior.
# We use pyEM's native fixed-prior support (prior_mu / prior_sigma) -- same
# EMModel, same fit function, just a prior instead of learning it from data.
from pyem import EMModel
# Build per-subject data rows [choices, rewards] from the simulated dataset.
map_data = [[sim_output['choices'][s], sim_output['rewards'][s]]
for s in range(nsubjects)]
# A gentle prior in the UNCONSTRAINED (normalized) space:
# beta ~ mid-range, alpha ~ 0.5. sigma=1 keeps it soft (a rubber band, not a clamp).
prior_mu = np.array([0.0, 0.0])
prior_sigma = np.array([1.0, 1.0])
_map_cache = os.path.join(CACHE_DIR, "map_fixedprior_params.npy")
map_model = EMModel(all_data=map_data, fit_func=rw1a1b_fit,
param_names=["beta", "alpha"],
param_xform=[norm2beta, norm2alpha])
if os.path.exists(_map_cache):
map_est_params = np.load(_map_cache)
# still need the fitted model object for its posterior; refit is cheap enough,
# but to stay instant we only reload the estimates for plotting.
print("Loaded cached MAP estimates.")
else:
# mstep_maxit=1 keeps the prior FIXED: a single MAP pass at the prior we
# specified, rather than letting EM re-learn the group prior. This is the
# genuine 'MAP with a fixed prior' beat (EM comes next and learns it).
map_model.fit(prior_mu=prior_mu, prior_sigma=prior_sigma, verbose=0,
mstep_maxit=1, max_restarts=1)
map_est_params = map_model.subject_params() # natural space (beta, alpha)
np.save(_map_cache, map_est_params)
print("Fit and cached MAP estimates.")
Loaded cached MAP estimates.
# Overlay MAP estimates on the MLE recovery scatter. The prior pulls the wild,
# boundary-slamming MLE points inward toward plausible values.
fig, axes = plt.subplots(1, 2, figsize=(8, 4))
for i, name in enumerate(rl_param_names):
axes[i].scatter(rl_params[:, i], rl_est_params[:, i],
color='0.6', alpha=0.4, s=45, label='MLE')
axes[i].scatter(rl_params[:, i], map_est_params[:, i],
color='seagreen', alpha=0.6, s=45, label='MAP')
lo = min(rl_params[:, i].min(), 0)
hi = rl_params[:, i].max()
axes[i].plot([lo, hi], [lo, hi], 'k--', lw=1)
axes[i].set_xlabel(f'Simulated {name}')
axes[i].set_ylabel(f'Estimated {name}')
axes[i].set_title(name)
if i == 0:
axes[i].legend(loc='upper left', frameon=False)
sns.despine(ax=axes[i])
fig.suptitle('MAP (fixed prior) vs MLE β boundary points pulled inward')
plt.tight_layout()
plt.show()
The prior clearly helped β the most extreme estimates got reeled in. But we just picked that prior by hand (Ξ² centered at mid-range, Ξ± at 0.5). That is arbitrary. Who should decide the prior?
The answer that motivates the rest of the talk: let the data decide. Estimate the group-level distribution and the individual parameters at the same time. That is exactly what hierarchical EM does.
Section 6 β Hierarchical EMΒΆ
MAP fixed the boundary problem, but it left an awkward question: who decides the prior? Picking it by hand is arbitrary. Hierarchical Expectation-Maximization learns the prior from the data itself, iterating between fitting individuals (E-step) and re-estimating the group distribution (M-step). Think of it as grading on a curve that gets recomputed each round until it stops moving.
Hierarchical modeling with Expectation-Maximization (EM)ΒΆ
Hierarchical modeling estimates parameters at two levels at once: individual-level parameters (each subject's Ξ±, Ξ²) and group-level "hyper-parameters" (their mean and variance). The EM algorithm alternates:
- E-step: given the current group distribution as a prior, estimate each individual's parameters (a MAP fit per subject).
- M-step: given those individual estimates, update the group-level distribution.
Repeat until it stops moving. It is "grading on a curve that gets recomputed" each round β the group tells each individual what's plausible, and the individuals collectively redefine the group.
1. Initialize the group-level distribution
2. Repeat until convergence:
a. E-step: fit each subject's params using the group as a prior
b. M-step: update the group distribution from those fits
3. Return individual + group-level estimates
We use the pyEM package, which implements exactly this.
# Hierarchical EM fit + parameter recovery. EM learns the group prior FROM THE
# DATA (no hand-picked prior), then re-fits everyone under it. Cached because
# the 80-subject EM loop is the slowest step in the talk.
_em_true = os.path.join(CACHE_DIR, "em_true_params.npy")
_em_est = os.path.join(CACHE_DIR, "em_est_params.npy")
_em_mu = os.path.join(CACHE_DIR, "em_posterior_mu.npy")
em_model = EMModel(all_data=None, fit_func=rw1a1b_fit,
param_names=["beta", "alpha"],
param_xform=[norm2beta, norm2alpha],
simulate_func=rw1a1b_sim)
if os.path.exists(_em_est) and os.path.exists(_em_mu):
em_true_params = np.load(_em_true)
em_est_params = np.load(_em_est)
em_posterior_mu = np.load(_em_mu) # NORMALIZED (Gaussian) space
print("Loaded cached EM recovery.")
else:
recovery = em_model.recover(rl_params, pr_inputs=['choices', 'rewards'],
nblocks=nblocks, ntrials=ntrials, seed=42)
em_true_params = recovery['true_params']
em_est_params = recovery['estimated_params'] # natural space
em_posterior_mu = recovery['recovery_model'].posterior()['mu'] # normalized space
np.save(_em_true, em_true_params)
np.save(_em_est, em_est_params)
np.save(_em_mu, em_posterior_mu)
print("Fit and cached EM recovery.")
# Third recovery scatter (best of the three)
fig, axes = plt.subplots(1, 2, figsize=(8, 4))
for i, name in enumerate(rl_param_names):
plotting.plot_scatter(em_true_params[:, i], f'Simulated {name}',
em_est_params[:, i], f'Estimated {name}',
ax=axes[i], colorname='seagreen')
fig.suptitle('Hierarchical EM β best recovery')
plt.tight_layout()
plt.show()
Loaded cached EM recovery.
Shrinkage: what EM actually bought usΒΆ
Notice the EM estimates hug the diagonal much more tightly than the independent MLE fits, especially at the extremes. That is shrinkage: EM pulls each noisy individual estimate toward the group center, like a rubber band. Subjects with little information get pulled hardest; subjects with clean data barely move.
This is the classic pooling spectrum:
- No pooling = independent MLE (every subject on their own β high variance).
- Complete pooling = one set of parameters for everyone (ignores individual differences β high bias).
- Partial pooling = EM / hierarchical (borrow strength across subjects β the sweet spot).
If you have fit mixed-effects models, this is the same idea: the individual parameters are like random effects, the group mean is like a fixed effect, and EM is just the estimator that ties them together. Let's make that analogy concrete with statsmodels on the linear-regression data, then show the shrinkage arrows on the RL fit.
# Draw the shrinkage: MLE (independent) vs EM (hierarchical), with the group
# center EM learned. CORRECTNESS: em_posterior_mu is in NORMALIZED space, so we
# transform it through norm2beta / norm2alpha before plotting in natural units.
group_center_natural = np.array([norm2beta(em_posterior_mu[0]),
norm2alpha(em_posterior_mu[1])])
print(f"EM group center (natural): beta={group_center_natural[0]:.2f}, "
f"alpha={group_center_natural[1]:.2f}")
# The y-axis is arbitrary here β jitter just spreads overlapping points apart so
# the shrinkage arrows are legible. Each subject gets ONE y-value, shared by its
# MLE point, its EM point, and its arrow, so arrows stay horizontal.
_jit_rng = np.random.default_rng(0)
jitter_h = 0.4 # vertical spread; purely cosmetic
fig, axes = plt.subplots(1, 2, figsize=(9, 4))
for i, name in enumerate(rl_param_names):
y = _jit_rng.uniform(-jitter_h, jitter_h, nsubjects)
# arrows from each MLE estimate to its EM estimate (the rubber band)
for s in range(nsubjects):
axes[i].annotate('', xy=(em_est_params[s, i], y[s]),
xytext=(rl_est_params[s, i], y[s]),
arrowprops=dict(arrowstyle='->', color='0.75', lw=0.6))
axes[i].scatter(rl_est_params[:, i], y,
color='0.6', s=30, label='MLE', zorder=3)
axes[i].scatter(em_est_params[:, i], y,
color='seagreen', s=30, label='EM', zorder=3)
axes[i].axvline(group_center_natural[i], color='crimson', ls='--',
label='EM group center')
axes[i].set_ylim(-1, 1) # give the jitter some breathing room
axes[i].set_yticks([])
axes[i].set_xlabel(name)
axes[i].set_title(f'{name}: estimates shrink toward the group center')
if i == 0:
axes[i].legend(loc='upper right', frameon=False, fontsize=8)
sns.despine(ax=axes[i], left=True)
plt.tight_layout()
plt.show()
EM group center (natural): beta=2.99, alpha=0.47
Note on
posterior_mu. Despite the name,posterior_mufrompyEMis an empirical-Bayes point estimate of the group mean β the single best group-level setting EM found β not a full Bayesian posterior distribution. It plays the role of the "fixed effect" here.Correctness trap:
posterior_mulives in the normalized (unconstrained) space, whilesubject_params()returns natural (Ξ±, Ξ²). Before comparing them on the same axes you must transformposterior_muback throughnorm2beta/norm2alpha, or the group center lands in the wrong place.
# A compact linear-regression dataset + one EM fit, purely to make the
# "random effects / fixed effects / EM" analogy concrete against statsmodels.
# (This reuses the SAME EMModel machinery we used for the RL model.)
def map_lm(params, y, X, prior=None, output='npl'):
y_pred = np.dot(X, params)
negll = -np.sum(scipy.stats.norm.logpdf(y, y_pred))
if output == 'nll':
return negll
if output == 'all':
return {'params': params, 'y_pred': y_pred, 'negll': negll}
if output == 'npl' and prior is not None and hasattr(prior, 'logpdf'):
return -(-negll + prior.logpdf(np.asarray(params)))
return negll
lm_param_names = ['b0', 'b1', 'b2']
lm_nparams = len(lm_param_names)
lm_nsubjects, lm_ntrials = 100, 100
_rng = np.random.default_rng(2021)
lm_params = _rng.normal(0, 1, size=(lm_nsubjects, lm_nparams))
X_out = np.zeros((lm_nsubjects, lm_ntrials, lm_nparams))
Y_out = np.zeros((lm_nsubjects, lm_ntrials))
for s in range(lm_nsubjects):
X_out[s, :, 0] = 1.0
X_out[s, :, 1:] = _rng.normal(size=(lm_ntrials, lm_nparams - 1))
Y_out[s, :] = X_out[s] @ lm_params[s] + _rng.normal(size=lm_ntrials)
lm_all_data = [[y, x] for y, x in zip(Y_out, X_out)]
_lm_cache = os.path.join(CACHE_DIR, "lm_em_posterior_mu.npy")
if os.path.exists(_lm_cache):
lm_posterior_mu = np.load(_lm_cache)
print("Loaded cached LR EM group means.")
else:
lm_model = EMModel(all_data=lm_all_data, fit_func=map_lm,
param_names=lm_param_names)
lm_fit = lm_model.fit(verbose=0)
lm_posterior_mu = lm_fit.posterior_mu
np.save(_lm_cache, lm_posterior_mu)
print("Fit and cached LR EM group means.")
Loaded cached LR EM group means.
# Fit a mixed-effects model to the SAME data with statsmodels, then compare its
# fixed effects to pyEM's group means (posterior_mu). They should closely agree.
from statsmodels.regression.mixed_linear_model import MixedLM
rows = []
for s in range(lm_nsubjects):
for t in range(lm_ntrials):
rows.append([s, X_out[s, t, 0], X_out[s, t, 1], X_out[s, t, 2], Y_out[s, t]])
df = pd.DataFrame(rows, columns=['subject', 'x0', 'x1', 'x2', 'Y'])
me_result = MixedLM.from_formula('Y ~ 1 + x1 + x2', df, groups='subject').fit()
print(f'Parameter | Mixed-effects fixed effect | pyEM posterior_mu (group mean)')
print(f'b0 | {me_result.params["Intercept"]:+.3f} | {lm_posterior_mu[0]:+.3f}')
print(f'b1 | {me_result.params["x1"]:+.3f} | {lm_posterior_mu[1]:+.3f}')
print(f'b2 | {me_result.params["x2"]:+.3f} | {lm_posterior_mu[2]:+.3f}')
print("\nSame idea, two toolboxes: random effects = individual params, "
"fixed effect = group mean, EM = the estimator.")
Parameter | Mixed-effects fixed effect | pyEM posterior_mu (group mean) b0 | -0.050 | -0.047 b1 | -0.048 | -0.026 b2 | +0.135 | +0.149 Same idea, two toolboxes: random effects = individual params, fixed effect = group mean, EM = the estimator.
# Summarizing improvements across methods: same recovery, three ways, worse -> better -> best.
methods = [('MLE (no pooling)', rl_est_params, '0.5'),
('MAP (fixed prior)', map_est_params, 'seagreen'),
('EM (learned prior)', em_est_params, 'crimson')]
fig, axes = plt.subplots(2, 3, figsize=(12, 8))
for col, (title, est, color) in enumerate(methods):
for row, name in enumerate(rl_param_names):
ax = axes[row, col]
r, _ = scipy.stats.pearsonr(rl_params[:, row], est[:, row])
ax.scatter(rl_params[:, row], est[:, row], color=color, alpha=0.55, s=40)
lo = min(rl_params[:, row].min(), 0); hi = rl_params[:, row].max()
ax.plot([lo, hi], [lo, hi], 'k--', lw=1)
ax.set_xlabel(f'Simulated {name}')
ax.set_ylabel(f'Estimated {name}')
ax.annotate(fr'$r={r:.2f}$', xy=(0.05, 0.9), xycoords='axes fraction')
if row == 0:
ax.set_title(title)
sns.despine(ax=ax)
fig.suptitle('One model, one recovery plot, three estimators: worse β better β best',
fontsize=13)
plt.tight_layout()
plt.show()
Section 7 β Trade-offs & a critical-reader checklistΒΆ
Trade-offs: what shrinkage costsΒΆ
| MLE (no pooling) | MAP (fixed prior) | Hierarchical EM | |
|---|---|---|---|
| Variance | high (noisy) | lower | lowest |
| Bias | ~none | some (toward your prior) | some (toward group) |
| Prior | none | you pick it (arbitrary) | learned from data |
| Assumptions | fewest | prior chosen by hand | group distribution is Gaussian-ish |
| Uncertainty | per-subject only | per-subject only | group prior treated as known (understated) |
| Compute | cheap | cheap | iterative (moderate) |
The headline: shrinkage buys you a lot less variance for a little bias. The cost is a distributional assumption about the group and some extra compute. And remember β EM gives a point estimate of the group prior, so it mildly understates uncertainty compared to full MCMC, which samples over all possible group priors.
A critical-reader checklistΒΆ
When you read (or write) a computational-modeling paper, ask:
- How many trials per subject? Few trials β individual MLE is unreliable; hierarchical methods matter more.
- MLE, MAP, or hierarchical? Were subjects fit independently, or was strength borrowed across the group?
- What was pooled, and what prior / group distribution was assumed? Partial pooling helps only if the group model is reasonable.
- EM (empirical Bayes) or full MCMC β and was parameter uncertainty reported? A point estimate of the group prior understates uncertainty; full Bayes propagates it.
Reflection (3 minutes)ΒΆ
What did you already know?
What have you learned?
What is still confusing?
CloseΒΆ
We fit one RescorlaβWagner model three ways β MLE, MAP, and hierarchical EM β and watched a single recovery plot go from noisy to clean as each method fixed the previous one's failure. Shrinkage is the workhorse of computational psychiatry: it lets short, noisy experiments still yield trustworthy individual parameter estimates.
AppendixΒΆ
Model ComparisonΒΆ
When fitting models to data, it is essential to use model comparison to evaluate the relative fit of different models. Common approaches to model comparison include (but are not limited to):
- Akaike Information Criterion (AIC): A measure that balances model fit and complexity, with lower AIC values indicating better models.
- Bayesian Information Criterion (BIC): Similar to AIC but penalizes model complexity more strongly, often leading to more simpler models.
- Log Model Evidence (LME): A Bayesian approach that computes the log evidence of the model given the data, allowing for direct comparison of models.
- WAIC (Widely Applicable Information Criterion): A Bayesian approach that estimates the out-of-sample predictive accuracy of the model, considering both fit and complexity.
- Cross-Validation: A technique that assesses how well a model generalizes to new data by splitting the data into training and testing sets.
Posterior Predictive ChecksΒΆ
Recovering parameters is only half the job. A model can fit (in the sense of minimizing surprise) and still be wrong about how the data were generated. A posterior predictive check (PPC) closes that gap by asking the question:
If we take the parameters we estimated and let the model generate its own data, does that synthetic data look like the real data?
The approach is simple:
- Take each subject's fitted parameters (here, the EM/MAP estimates in
em_est_params). - Let the model play the task itself by simulating new choices and rewards from those parameters.
- Repeat many times to get a distribution of predicted behavior (this is the "predictive" part as it carries the model's stochasticity).
- Compare summary statistics of the simulated data to the same statistics computed on the observed data.
If the observed data fall comfortably inside the cloud of model-generated data, the model reproduces the behavioral signatures we care about. If the observed data fall outside that cloud (e.g., the real learning curve is steeper than anything the model produces) that is a red flag the model is missing something, no matter how good the parameter recovery looked.
We check two things: the group learning curve (does P(choosing the good arm) rise over trials the way the data do?) and a per-subject summary (does each subject's overall preference for the good arm match what the model predicts for them?).
Caveat for this tutorial. Our "observed" data were simulated from the same RescorlaβWagner model we are fitting. So this PPC mostly confirms the fitting worked β it cannot reveal model misspecification, because there is none to find. On real data, where the true generating process is unknown, PPCs are far more valuable: they are one of the main ways you catch a model that fits the numbers but tells the wrong story. Treat this section as a demonstration of the machinery; the payoff comes when you point it at data you did not generate.
# Generate posterior predictive simulations from EM fits
# Let the fitted model play the task itself, many times, to build a
# DISTRIBUTION of predicted behavior. em_est_params holds the EM/MAP
# estimates in natural (beta, alpha) space β exactly what rw1a1b_sim wants.
n_ppc = 200 # number of synthetic datasets (more = smoother bands)
ppc_choicesA = np.zeros((n_ppc, nsubjects, ntrials))
for rep in range(n_ppc):
# a fresh seed each rep => fresh choices/rewards => captures model stochasticity
rep_sim = rw1a1b_sim(em_est_params, nblocks=nblocks, ntrials=ntrials, seed=1000 + rep)
ppc_choicesA[rep] = rep_sim['choices_A'][:, 0, :]
# Observed behavior (the data we actually fit)
obs_choicesA = sim_output['choices_A'][:, 0, :] # (nsubjects, ntrials)
# Learning curve: observed data vs model prediction
# Summary stat: P(choosing the optimal 80% arm) at each trial, averaged
# over subjects. The model's prediction is a BAND (2.5-97.5 percentile
# across the n_ppc synthetic datasets), not a single line.
obs_curve = obs_choicesA.mean(axis=0) # (ntrials,)
ppc_curve_bysim = ppc_choicesA.mean(axis=1) # (n_ppc, ntrials)
ppc_mean = ppc_curve_bysim.mean(axis=0)
ppc_lo = np.percentile(ppc_curve_bysim, 2.5, axis=0)
ppc_hi = np.percentile(ppc_curve_bysim, 97.5, axis=0)
# bin into blocks of `w` trials so the observed curve isn't jagged
w = 5
def _bin(x): return x[:(len(x)//w)*w].reshape(-1, w).mean(1)
tb = np.arange(len(_bin(obs_curve))) * w + w/2
fig, ax = plt.subplots(figsize=(6, 4))
ax.fill_between(tb, _bin(ppc_lo), _bin(ppc_hi), color='crimson', alpha=.2,
label='95% predictive band')
ax.plot(tb, _bin(ppc_mean), color='crimson', label='Model (PPC mean)')
ax.plot(tb, _bin(obs_curve), 'o-', color='black', zorder=3, label='Observed')
ax.axhline(0.5, color='0.7', ls=':')
ax.set_ylim(0, 1)
ax.set_xlabel('Trial')
ax.set_ylabel('P(choose optimal arm)')
ax.set_title('Learning curve: does the model reproduce the data?')
ax.legend(fontsize=8, frameon=False)
sns.despine()
plt.tight_layout()
plt.show()
# how often does the observed curve land inside the model's 95% band?
coverage = np.mean((obs_curve >= ppc_lo) & (obs_curve <= ppc_hi))
print(f"Observed learning curve inside 95% predictive band on "
f"{coverage*100:.0f}% of trials.")
# Per-subject check: predicted vs observed preference
# Summary stat per subject: overall P(choosing the optimal arm). If the
# points hug the diagonal, the model reproduces individual differences.
obs_popt = obs_choicesA.mean(axis=1) # (nsubjects,)
ppc_popt = ppc_choicesA.mean(axis=(0, 2)) # mean over reps & trials
ppc_popt_lo = np.percentile(ppc_choicesA.mean(axis=2), 2.5, axis=0)
ppc_popt_hi = np.percentile(ppc_choicesA.mean(axis=2), 97.5, axis=0)
r = np.corrcoef(obs_popt, ppc_popt)[0, 1]
fig, ax = plt.subplots(figsize=(5, 5))
ax.errorbar(obs_popt, ppc_popt,
yerr=[ppc_popt - ppc_popt_lo, ppc_popt_hi - ppc_popt],
fmt='o', color='crimson', alpha=.5, ecolor='0.8',
elinewidth=0.7, ms=5)
ax.plot([0, 1], [0, 1], 'k--', lw=1)
ax.set_xlim(0, 1); ax.set_ylim(0, 1)
ax.set_xlabel('Observed P(optimal), per subject')
ax.set_ylabel('Predicted P(optimal), per subject')
ax.set_title(f'Per-subject posterior predictive fit (r = {r:.2f})')
sns.despine()
plt.tight_layout()
plt.show()
Observed learning curve inside 95% predictive band on 99% of trials.
Additional ResourcesΒΆ
- Wilson, R. C., & Collins, A. G. (2019). Ten simple rules for the computational modeling of behavioral data. eLife, 8, e49547. doi: 10.7554/eLife.49547 https://elifesciences.org/articles/49547
- Daw, N. D. (2011). Trial-by-trial data analysis using computational models. Decision making, affect, and learning: Attention and performance XXIII, 23(1). doi: 10.1093/acprof:oso/9780199600434.003.0001 https://www.princeton.edu/~ndaw/d10.pdf
- Rhoads, S. A. (2023). pyEM: Expectation Maximization with MAP estimation in Python. doi: 10.5281/zenodo.10415396 https://github.com/shawnrhoads/pyEM
- Rhoads, S. A. & Gan, L. (2022). Computational models of human social behavior and neuroscience: An open educational course and Jupyter Book to advance computational training. Journal of Open Source Education, 5(47), 146. doi: 10.21105/jose.00146 https://shawnrhoads.github.io/gu-psyc-347/