AI Scientist · Part 3
Updating Beliefs with Observations
3 September 2026
- ai scientist
- exploration
- implementation notes
In Parts One and Two we set up the conceptual components of an AI scientist, defining the World with which it interacts, and the notion of Observations, experiment Designs and how experimental designs would be chosen with an eye to optimal information gain and model distinguishability. We also looked at how we might score a Hypothesis in a pool using the Bayes Factor and other metrics.
To integrate information from actual data into understanding which explanation is correct, we are always doing some form of inference. That can be entirely implicit (as humans we do that all the time), or we can make it explicit as inferring posterior distributions over explanations. However, so far we have assumed that we can exactly compute these values, and the resulting evidence for different models. Our toy model allowed us to do exact inference.
What happens when we can no longer do that? We may not be in a situation where our models can neatly provide log likelihoods and posteriors are non-trivial to obtain. This post addresses how the two main papers of interest(*reference to footnote with the references) address that problem, using the machinery we have built up so far. In addition, the code is written to allow for an LLM-driven model to do this implicitly.
This post focuses on parameter inference within each assumed model. Part 3b then compares the models and tests how inference affects predictions and experiment choices. We keep the evidence as a posterior normalizing constant here, but move model scoring and selection to that companion.
We'll keep using our simple LineWorld example and our pool of hypotheses -- no exciting Worlds or LLM-driven Proposers yet -- that's coming soon!
Summary of contents: how to infer parameters for a fixed model
How do we make approximate methods recover each model's parameter posterior?
- Define the observed data, likelihood, prior and parameter posterior.
- Inspect prior importance sampling and tempered SMC.
- Train a neural posterior estimator using labelled simulations.
- Check parameter means, covariances and interval coverage against an exact reference.
- Continue to Part 3b: comparing and selecting models.
The constant, line and quadratic hypotheses are treated separately here; we do not rank them. Part 3b contains both evidence-based and neural model comparison.
1. What is being inferred, and from which data?
Fix a candidate model and let be its coefficient vector. For a line, these are its intercept and slope. We infer those coefficients conditional on this model, without deciding whether the model itself is the right explanation.
- contains four measured outputs together with their known settings.
- and are the setting and measured output of additional experiment .
- is the complete measured history after additional experiments. Repeats are separate records. Here we use ; Part 3b adds experiments.
- is the likelihood: the density of the fixed measured outputs, conditional on their known settings, viewed as a function of .
- is the coefficient prior.
Bayes' rule gives
normalizes this density over coefficients. It is also called the model evidence, but comparing evidences belongs to Part 3b. The integral uses the prior, not the posterior already fitted to these observations.
An approximate posterior representation need not supply an estimate of . MCMC, for example, can target the normalized posterior without evaluating this integral; NPE learns a normalized conditional density from simulations.
1.1 Approaches to inference
We can map out a few different approaches to inference, (note we've already used exact inference because we could):
- Exact inference / numerical quadrature: evaluate posterior integrals analytically when possible, or approximate them by weighted evaluations on a grid. A grid becomes expensive as the number of parameters grows. Our toy has an analytic reference.
- MCMC: Explore a posterior with a chain of samples, avoiding the need to calculate an integral over parameters. But if you actually want to calculate the evidence you need to do extra work. We do not build a standalone MCMC sampler here, but the Metropolis moves inside Murphy's SMC are MCMC steps.
- Importance sampling / SMC: Approximate distributions with weighted parameter samples, we explore below in Sections 2 and 3 as this was used extensively in Murphy's parameter inference.
- Variational inference: Fit a chosen distribution to approximate the posterior, we don't explore here, for now.
- Neural Posterior Estimation (NPE): Learn posteriors for parameters conditional on simulated observations as in program-synthesis SBI parameter inference. See Section 4..
- Neural likelihood / ratio estimation: learn observation densities or density ratios from simulations. The model-ratio route is in Part 3b.
Distinguish the likelihood cannot be evaluated from a likelihood can be evaluated but its parameter integral is hard. We start in the second case, using an exactly solvable toy as a reference. The neural route deliberately learns from simulations without calling that analytic reference during training.
1.2 Representing the posterior
We have the ParameterPosterior object from before, where we specifically looked at the GaussianPosterior. We are now going to add ParticlePosterior objects for sampling methods and GaussianNeuralPosterior for learned methods.
These store and expose parameter moments (eg. mean and covariance in the Gaussian case), have a sample method to allow sampling from the distribution as well as predict to allow prediction of values at a given input or design, for instance predicting noise free means. They do not all promise a smooth density or a Gaussian shape. The neural version here happens to be a conditional Gaussian, it doesn't have to be.
Show code: the posterior interface, world, and pool
for cls in (GaussianPosterior, ParticlePosterior, GaussianNeuralPosterior):
print(cls.__name__, "inherits", cls.__bases__[0].__name__)
assert issubclass(cls, ParameterPosterior)
print(inspect.getsource(ParameterPosterior))
SIGMA = 0.2
INITIAL_X = (-2.0, -1.0, 0.0, 1.0)
MENU_X = (-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0)
world = LineWorld(a=2, b=1, sigma=SIGMA, grid=MENU_X)
pool = tuple(PolynomialHypothesis(degree, prior_scale=3, name=name)
for degree, name in enumerate(("constant", "line", "quadratic")))
measurement_rng = np.random.default_rng(7)
data = tuple(world.run(Design.from_dict({"x": x}), measurement_rng) for x in INITIAL_X)
exact_parameters = {h.name: fit_parameters(h, data, SIGMA) for h in pool}
table([{"x": obs.design["x"], "observed y": obs.y[0]} for obs in data])
assert len(data) == 4GaussianPosterior inherits ParameterPosterior
ParticlePosterior inherits ParameterPosterior
GaussianNeuralPosterior inherits ParameterPosterior
class ParameterPosterior(ABC):
"""Coefficient uncertainty for the current polynomial examples.
Predictions are noise-free moments, not necessarily Gaussian densities.
Samples have shape (n, hypothesis.n_params). This deliberately does not
require density evaluation: a weighted particle measure has no smooth PDF.
"""
hypothesis: PolynomialHypothesis
mean: Array
covariance: Array
@abstractmethod
def sample(self, rng: np.random.Generator, n: int) -> Array: ...
@abstractmethod
def predict(self, inputs: Array) -> tuple[Array, Array]: ...| x | observed y |
|---|---|
| -2 | -2.9998 |
| -1 | -0.94025 |
| 0 | 0.94517 |
| 1 | 2.8219 |
2. Prior importance sampling
We have a fixed model and fixed data , and want a posterior over its coefficients .
Draw parameter vectors from the prior, , and evaluate the observed-data likelihood at each. Here indexes parameter particles, not observations. Define their log likelihoods and normalized weights :
Because the proposal is the prior, the prior/proposal density ratio cancels. The weighted particles approximate the parameter posterior, with mean .
Weight effective sample size (ESS) is . It ranges from 1 (all weight on one particle) to (equal weights). It measures weight concentration, not how well the particles cover parameter space. High ESS does not establish that we have found the posterior.
We can inspect the particles and weights returned by polynomial_importance_fit. Its ParticleFit record also contains a normalizing-constant estimate, used in Part 3b.
Show code
# Set up world and initial measurements
SIGMA = 0.2
INITIAL_X = (-2.0, -1.0, 0.0, 1.0)
MENU_X = (-3.0, -2.0, -1.0, 0.0, 1.0, 2.0, 3.0)
world = LineWorld(a=2, b=1, sigma=SIGMA, grid=MENU_X)
measurement_rng = np.random.default_rng(7)
data = tuple(world.run(Design.from_dict({"x": x}), measurement_rng) for x in INITIAL_X)
# Propose a hypothesis
line = PolynomialHypothesis(1, prior_scale=3, name="line")
# Use the importance_fitting function that PolynomialParticleUpdater would use to update Beliefs.
prior_fit: ParticleFit = polynomial_importance_fit(line, data, SIGMA, n_particles=1500,
rng=np.random.default_rng(31))
trace = prior_fit.steps[0]
largest = np.argsort(trace.weights)[-8:][::-1]
# Fit each model's exact parameter posterior for comparison.
exact_parameters = {h.name: fit_parameters(h, data, SIGMA) for h in pool}
table([{"particle": int(j), "intercept": trace.particles_before[j, 0],
"slope": trace.particles_before[j, 1],
"log likelihood": trace.log_likelihoods[j], "weight": trace.weights[j]}
for j in largest])
print("Parameter weight ESS:", trace.ess, "of", len(trace.weights))
print("Estimated / exact coefficient mean:", prior_fit.posterior.mean, exact_parameters["line"].mean)| particle | intercept | slope | log likelihood | weight |
|---|---|---|---|---|
| 1216 | 0.70777 | 1.9512 | 0.1027 | 0.37964 |
| 525 | 1.1704 | 2.1064 | -0.4811 | 0.21175 |
| 477 | 0.67244 | 1.7598 | -0.62673 | 0.18306 |
| 1202 | 0.65342 | 1.7481 | -1.1171 | 0.11211 |
| 1009 | 0.99523 | 1.734 | -1.3566 | 0.088225 |
| 943 | 1.2761 | 2.1041 | -2.7184 | 0.022605 |
| 300 | 0.64444 | 1.5975 | -5.0954 | 0.0020984 |
| 1484 | 1.3793 | 1.978 | -6.8742 | 0.00035426 |
Parameter weight ESS: 4.109384153487241 of 1500
Estimated / exact coefficient mean: [0.83149 1.90979] [0.92219 1.93291]
Clearly, after an importance sampling step we haven't managed to exactly find the posterior of the parameters. The challenge with IS is that it is reliant on our prior proposal distribution being a good choice, so that our particles are actually exploring useful areas of parameter space.
This motivates a method that permits movement within the sampling space, rather than being strongly influenced by the initial draw from a prior. Hence, the use of Sequential Monte Carlo (SMC).
3. Sequential Monte Carlo: move to the posterior by introducing the observed likelihood gradually
SMC reduces our reliance on the initial prior draws by repeatedly reweighting, resampling and moving the particles. The prior remains part of the model. Movement helps reach regions missed by the initial draws, but finite runs can still miss posterior modes, and zero prior support cannot be overcome.
We keep model and observed data fixed. Write for the likelihood of those observations.
3.1 Move from prior to posterior
We gradually increase from 0 to 1:
At this is the prior; at it is the posterior. Intermediate values introduce the likelihood gradually, without collecting new data.
Why these intermediate distributions connect prior and posterior
Write to keep the equations shorter. This is the likelihood of the fixed observed data as a function of the coefficients.
Choose a sequence . Here indexes numerical steps, is their total number, and is how much of the log likelihood we include. It is not a physical temperature.
At any , define the normalizing constant
and therefore the normalized density
Check the endpoints:
- At , , so and : the prior.
- At , and : the posterior.
In log form,
So we gradually increase the influence of the log likelihood, without changing the prior or the data.
3.2 Reweight the particles
When we increase by , we multiply each incoming particle weight by the additional likelihood factor, then normalize:
Here is a current parameter particle, its incoming normalized weight, and its new weight before resampling. We use only the increase in likelihood power: the previous target already included the earlier power; this reweighting is taking in to account the size of the step and the likelihood at the new position of the particle to reweight, so the higher likelihood particles are upweighted.
Derive the incremental particle weights
Suppose our incoming particles , for , and normalized weights approximate . Here is the number of parameter particles, and .
We now want them to represent . Importance reweighting multiplies each existing weight by new target density / old target density, evaluated at the same coefficient vector:
The prior cancels in this ratio at the same , because both targets use the same prior. It has not disappeared from either target. The ratio of normalizing constants is also the same for every particle, so it cancels when we normalize the new weights.
Define
The incremental likelihood factor is
Consequently, the unnormalized weights and then normalized weights are
The tilde distinguishes these pre-resampling weights from the equal weights after resampling. The log likelihood belongs to the particle's current position; after particles move, their likelihoods must be updated too.
3.3 Resample, then move
Resample particles according to their new weights, then reset the weights to , where is the population size. This copies promising particles, but doesn't create new parameter values.
Next, use Metropolis–Hastings moves to explore nearby values while preserving the current target . These moves account for both the prior and the tempered likelihood. They help prevent the population from becoming just a collection of duplicates.
Derive the Metropolis acceptance rule
Resampling. Draw ancestor indices with probabilities . Copy the corresponding parameter vectors and give each copy weight . A particle with weight receives copies on average, not necessarily exactly that many.
This redistributes computational effort. It does not produce new parameter values, and equal weights do not mean we now have distinct or independent particles.
Movement. At the same fixed , use a Metropolis–Hastings step. Let be one current particle and a proposed new vector, drawn from a proposal density . The acceptance rule is
Our random-walk proposal is symmetric: . Its covariance is held fixed during the moves at this temperature. Cancel the proposal terms and substitute the intermediate target:
Here the normalizing constant cancels, but the prior generally does not: we are comparing two different parameter vectors. This differs from the temperature reweighting in Section 3.2, where the parameter vector was held fixed.
For our independent zero-mean Gaussian coefficient prior with standard deviation ,
where is the sum of squared coefficients. The log acceptance ratio is therefore
Draw uniformly on and accept if ; otherwise keep the old particle.
These moves are constructed to preserve the current target distribution. A few moves help exploration but do not make a finite particle population exact. They do not add a new evidence increment: and its normalizing constant have not changed.
3.4 Put the steps together
Our implementation resamples at every temperature, so incoming weights are always . The reweighting calculation is:
increment = (next_beta - beta) * ell
weights = np.exp(increment - logsumexp(increment))
Here ell contains current log likelihoods, and logsumexp evaluates the log of the sum of exponentials stably.
Repeat increase → reweight → resample → move until . Below we inspect a fixed schedule. The code also tracks normalizing constants; their use for evidence-based model comparison is explained in Part 3b. Murphy's parameter-SMC algorithm motivates this component; model search and latent-state inference remain separate.
smc_fit: ParticleFit = polynomial_tempered_smc(
line, data, SIGMA, n_particles=1500, rng=np.random.default_rng(31),
temperatures=[0, 0.001, 0.005, 0.02, 0.08, 0.25, 0.6, 1],
moves=5,
)
table([{"rung": k+1, "beta": step.beta, "ESS before resampling": step.ess,
"MH acceptance": step.acceptance_rate}
for k, step in enumerate(smc_fit.steps)])
print("Full-dataset likelihood evaluations:", smc_fit.likelihood_evaluations)
assert smc_fit.steps[-1].beta == 1
print("SMC / exact coefficient mean:", smc_fit.posterior.mean, exact_parameters["line"].mean)
| rung | beta | ESS before resampling | MH acceptance |
|---|---|---|---|
| 1 | 0.001 | 1003.1 | 0.35067 |
| 2 | 0.005 | 796.37 | 0.3544 |
| 3 | 0.02 | 698.35 | 0.35347 |
| 4 | 0.08 | 655.94 | 0.3596 |
| 5 | 0.25 | 782.65 | 0.3556 |
| 6 | 0.6 | 975.7 | 0.35187 |
| 7 | 1 | 1268.1 | 0.3592 |
Full-dataset likelihood evaluations: 54000
SMC / exact coefficient mean: [0.91614 1.92836] [0.92219 1.93291]
The next plot shows how the parameter population changes. Compare its final location and spread with the exact posterior, rather than judging it only by the particle weights.
SMC concentrates plausible values of the line's coefficients
step 1 of 8
Each dot is one possible pair in y = θ₀ + θ₁x, not an observed data point. The same 4 observations are used throughout; the axes zoom in at each stage.
Draw from the prior β = 0
- 375 of 1,500 particles
- exact posterior, 95% joint region
- ✕ exact posterior mean
- area enlarged next
4. Learn the posterior instead
The idea here is to learn the posterior: given noisy outputs, predict a distribution over the coefficients that could have produced them.
For each candidate model , create a separate collection of simulated datasets:
- is a coefficient vector drawn from model 's prior; for our simple line example, it contains an intercept and a slope.
- is the ordered list of known settings. contains one simulated noisy output at each setting, all generated using the same .
- indexes simulated datasets, not individual experiments. is this synthetic collection, not the real observed dataset .
Our estimator is a normalized density over coefficients , given outputs . Its trainable parameters are the network's weights, biases, and covariance parameters—not the scientific coefficients .
Use the first pairs for training and hold out the rest for validation. Below, per model: 2400 training pairs and 600 validation pairs. Fit by minimizing
This is the mean negative log density, labelled NLL in the code. We reward the estimator for assigning high density to the known generating coefficients. This is not the observation likelihood: we score coefficients given outputs, not outputs given coefficients. No exact posterior labels are needed.
After training, we can supply , the actual outputs from in the same setting order. The resulting approximates our parameter posterior.
Here PyTorch learns a Gaussian with a linear mean and a full covariance matrix. This family can represent the true toy posterior; we could have instead used a flow, which will be implemented in the code for our final fair comparison. Each estimator is fitted for one model and one ordered setting list. We must retrain when that list changes.
Note something important: the neural posterior estimator is trained to capture the behaviour of a hypothesis, and then effectively "transferred" by inputting the true observations. If the hypothesis is really far from the truth, the observations will be far from the training data for this parameter set, so we might expect high error.
Here we call train_npe and condition_npe directly, so no model-comparison network is trained. Part 3b combines parameter inference with model weights through NeuralUpdater.
Show code: training the neural posterior estimators
print(inspect.getsource(ConditionalGaussian))
npe_rng = np.random.default_rng(40)
started = perf_counter()
npe_fits = {
h.name: train_npe(h, INITIAL_X, SIGMA, n_simulations=3000, max_iter=220,
seed=int(npe_rng.integers(0, 2**31-1)))
for h in pool
}
observed_y = np.array([obs.y[0] for obs in data])
neural_parameters = {h.name: condition_npe(npe_fits[h.name], h, observed_y) for h in pool}
table([{"model": name, "simulated datasets for NPE": fit.simulations,
"initial train NLL": fit.train_losses[0],
"final train NLL": fit.train_losses[-1], "validation NLL": fit.validation_loss}
for name, fit in npe_fits.items()])
print("NPE fitting time (no model comparison):", round(perf_counter()-started, 3), "seconds")
assert all(isinstance(p, GaussianNeuralPosterior) for p in neural_parameters.values())class ConditionalGaussian(nn.Module):
"""Inspectable NPE: linear conditional mean and a shared learned Cholesky factor.
A neural density estimator need not start as a deep network. For this toy,
the exact conditional mean is linear and covariance does not depend on y.
A richer family is needed for nonlinear means or multimodal posteriors.
Outputs are in prior-standardized coefficient coordinates.
"""
def __init__(self, n_observations, n_parameters):
super().__init__()
self.mean_head = nn.Linear(n_observations, n_parameters, dtype=torch.float64)
nn.init.zeros_(self.mean_head.weight)
nn.init.zeros_(self.mean_head.bias)
self.raw_cholesky = nn.Parameter(torch.zeros(n_parameters, n_parameters, dtype=torch.float64))
def scale_tril(self):
"""Exponentiate the diagonal to make covariance positive definite."""
raw = self.raw_cholesky
return torch.tril(raw, diagonal=-1) + torch.diag(torch.exp(torch.diag(raw)))
def forward(self, standardized_y):
"""Return q(theta_standardized | y), not a distribution over observations."""
return torch.distributions.MultivariateNormal(
self.mean_head(standardized_y), scale_tril=self.scale_tril())| model | simulated datasets for NPE | initial train NLL | final train NLL | validation NLL |
|---|---|---|---|---|
| constant | 3000 | 1.4003 | -2.0035 | -1.9749 |
| line | 3000 | 2.8027 | -4.1264 | -4.0903 |
| quadratic | 3000 | 4.2596 | -6.0669 | -6.095 |
NPE fitting time (no model comparison): 0.499 secondsA low training loss is not enough.
We make two different checks below. Neither uses new real-world observations.
Coverage: does the uncertainty behave as expected? Generate 1000 fresh simulated datasets from the line model, using coefficients drawn from its prior. These datasets were not used for training or validation. For each one:
- Give its noisy outputs to the trained NPE estimator.
- Form a 95% posterior interval for each coefficient: posterior mean posterior standard deviations. This formula applies because our estimator returns a Gaussian.
- Check whether the interval contains the coefficient that actually generated that dataset.
Coverage is the fraction of these 1000 intervals that contain their generating coefficient. For example, 950 successes means 95% coverage. The table and left plot report this separately for the intercept and slope; these are marginal intervals, not a joint region for both coefficients. Values near 95% are the target, with some variation from the finite test sample. Lower coverage means the intervals miss too often; higher coverage means they contain the generating values more often than the stated level.
This checks performance averaged over our prior and simulator. It does not establish accuracy for our particular real dataset, or for a world that differs from the simulator. Even returning the prior every time can achieve 95% average coverage while ignoring the observations. We therefore also compare posterior means and covariances with the exact solution later in the post.
Training loss: did fitting improve the estimator? The right plot tracks the average negative log density assigned to the known training coefficients; lower is better. Each curve is a separate model's estimator, not a score for choosing that model. Coefficients are scaled by their prior standard deviation during training, so these losses are not model evidences.
The horizontal axis counts optimizer trial evaluations, including rejected trials—not epochs or new datasets. Temporary spikes can occur. The symmetric-log vertical scale compresses large magnitudes while retaining zero and negative losses. Negative values are possible because a probability density can exceed one.
Training progress: not a model ranking
A separate estimator is trained for each model, on 3,000 simulated datasets drawn from that model's own prior. The losses are therefore not comparable between models.
- constant
- line
- quadratic
5. Check the inferred coefficients without choosing a model
We can compare each approximate posterior with the exact posterior under the same assumed model and data. The constant model also has a well-defined posterior even though it cannot represent the world's slope.
The error in the mean is the L2 norm of the difference between coefficient means. The error in the covariance is the Frobenius norm: the square root of the sum of squared entrywise differences. Zero means agreement with the exact reference.
We can see that different inference methods produce different results, and that is also dependent on the Hypothesis model under test. But we should remember that numerical error is not the same as scientific uncertainty, which remains even under exact inference.
Show code
is_rng, smc_rng = np.random.default_rng(31), np.random.default_rng(31)
parameter_methods = {
"Exact": exact_parameters,
"Prior IS": {h.name: polynomial_importance_fit(h, data, SIGMA, n_particles=1500, rng=is_rng).posterior for h in pool},
"Tempered SMC": {h.name: polynomial_tempered_smc(h, data, SIGMA, n_particles=1500, rng=smc_rng).posterior for h in pool},
"NPE": neural_parameters,
}
table([{"method": method, "assumed model": h.name,
"mean error L2": np.linalg.norm(fits[h.name].mean-exact_parameters[h.name].mean),
"covariance error Frobenius": np.linalg.norm(fits[h.name].covariance-exact_parameters[h.name].covariance)}
for method, fits in parameter_methods.items() for h in pool])
assert len(data) == 4| method | assumed model | mean error L2 | covariance error Frobenius |
|---|---|---|---|
| Exact | constant | 0 | 0 |
| Exact | line | 0 | 0 |
| Exact | quadratic | 0 | 0 |
| Prior IS | constant | 0.00386 | 0.0014029 |
| Prior IS | line | 0.14657 | 0.013054 |
| Prior IS | quadratic | 0.36056 | 0.03661 |
| Tempered SMC | constant | 0.0043682 | 5.6744e-05 |
| Tempered SMC | line | 0.0020837 | 0.00041317 |
| Tempered SMC | quadratic | 0.010506 | 0.0027707 |
| NPE | constant | 0.037849 | 0.00040501 |
| NPE | line | 0.0016978 | 0.00060728 |
| NPE | quadratic | 0.012403 | 0.00075851 |
Next: which model should receive our trust?
We now have parameter posteriors conditional on each model, but they don't tell us how much probability to assign to the models themselves, which we need to perform model selection from the Hypothesis pool, or indeed know whether ot expand the pool.
Continue to Part 3b: comparing and selecting models.