Introduction
In the previous post, we introduced the Feynman-Kac equation by starting with a space of functions and equipping it with the Markov semigroup. From there, the infinitesimal generator and its adjoint gave us two (dual) ways to describe the same evolution of random information: we could evolve a function backward from a future observation (terminal boundary condition), or evolve some initial distribution forward. Then Feynman-Kac appeared where those two descriptions met.
This time, we will make it concrete in a SageMath Jupyter Notebook.
If the expectation is difficult but the PDE is tractable, then solve the PDE (usually numerically).
If the PDE is difficult, simulate paths ofand estimate the expectation by Monte Carlo.
We will use geometric Brownian motion and a simple function so that we can follow every step without losing the main idea in all the algebra. First, we will use the generator that we built in Python to verify the backward Kolmogorov equation for the expected future value of
. Then we will use the adjoint to verify the forward Kolmogorov equation for the transition density. Finally, we will bring the two sides together: by integrating
against that density, we will recover exactly the function that solves the backward Kolmogorov equation.
The aim here is to make this connection visible and practical, using only Python objects that we have already built. By the end, the backward PDE solution, the forward probability distribution, and the conditional expectation should really feel like three ways of looking at the same object.
Recall from the previous blog we ended with this picture:

Notebook Imports
Let’s first define these imports and helper functions.
# noinspection PyUnresolvedReferencesfrom sage.all import var, SR, exp, sqrt, latex, log, pi, assumefrom Sde.sde import SDE1D # type: ignorefrom Ito.ito_lemma import ito_lemma_1dfrom Ito.kolmogorov import backward_kolmogorovfrom IPython.display import display, Mathdef show_eq(lhs, rhs): display(Math(rf"{lhs} = {latex(rhs)}"))def show_text_math(text, expr): display(Math(rf"\text{{{text}}}\quad {latex(expr)}"))
Define the Markov Diffusion
We begin with geometric Brownian motion
Its future evolution depends on its current value, rather than the path it took to get there. This makes it a Markov diffusion. To see why, suppose we know that . Over the next small interval, its drift is
and its diffusion coefficient is
. For geometric Brownian motion, the same point holds over any future interval, in other words, once we know
, the earlier path gives us no further information about the distribution of
at the terminal boundary. The current state is enough to determine the probabilities of future states.
Recall from the previous post, that this lets us define a Markov operator which takes a function
and returns its expected value after an elapsed time
:
The infinitesimal generator measures how quickly that expectation changes as
starts to increase from zero. For our geometric Brownian motion, it is
We represent the diffusion using our SDE1D class that we built earlier in this series:
t, T, x, mu, sigma = var("t T x mu sigma")gbm = SDE1D( t=t, x=x, drift=mu*x, diffusion=sigma*x, name="GBM")
Now we choose the function we will follow for the rest of the notebook, . We can ask the
SDE1D object to apply its spatial generator directly:
f = x**2Lf = gbm.generator(f)show_eq(r"f(x)", f)show_eq(r"\mathcal{L}[f]", Lf)
The result is
The generator has returned the instantaneous evolution of this observable function. The term comes from the drift, while the extra
term comes from the diffusion.
Next, we will turn this instantaneous description into a function that tells us the expected value of at a future time
.

Compute the Conditional Expectation
Now that we have seen what the generator does to over an infinitesimal time interval, let us now calculate the expected value after a finite time interval
.
Luckily for us, geometric Brownian motion has an exact transition formula. Conditional on , we have
where .
The random variable accounts for the Brownian motion accumulated between times
and
. Squaring this expression and taking its expectation gives:
which simplifies to
where in this last line, we used the standard Normal identity .
Let us call this conditional expectation :
tau = T - tu = x**2 * exp((2*mu + sigma**2) * tau)show_eq(r"u(t,x)", u)
The variable tau (or sometimes I use ) is the time remaining until
. This matters because
tells us, at time
, what we expect our future observation
to be.
Before checking any PDE, we should first check what happens at the terminal time. When , there is no time left for
to evolve. We therefore know precisely that
, and therefore that
. Hence,
terminal_residual = (u.subs(t=T) - f).simplify_full()show_eq(r"u(T,x)", u.subs(t=T))show_eq(r"u(T,x) - f(x)", terminal_residual)assert terminal_residual == 0
SageMath returns zero for the residual!
We have now constructed from a conditional expectation and confirmed its terminal condition.
The next question is whether this same function also solves the backward Kolmogorov PDE.
Verify the Backward Kolmogorov PDE
We obtained by taking a conditional expectation. Now we will approach the same function from the PDE side.
For our geometric Brownian motion, we showed in a previous blog that the backward Kolmogorov equation is
with terminal condition .
The spatial terms are precisely , so we can write the equation more compactly as,
Why the plus sign when we are working backward? The terminal time is fixed. As the current time
moves forward, the remaining time
gets shorter. The time derivative
therefore offsets the evolution described by the generator.
Our backward_kolmogorov() method now computes the left-hand side of the above PDE. If the conditional expectation we found really solves it, then the result should simplify to zero:
pde_residual = backward_kolmogorov( u=u, t=t, state_vars=x, drift=mu*x, diffusion=sigma*x, simplify_result=True)show_eq(r"u_t+\mathcal{L}[u]", pde_residual)assert pde_residual == 0
And it does!
We can check the same calculation through our SDE1D object, whose Ito operator applies to a function:
sde_operator_residual = gbm.ito_operator(u)show_eq( r"(\partial_t+\mathcal{L})[u]", sde_operator_residual)assert sde_operator_residual == 0
And as we can see, both routes give zero!
Thus, together with the terminal condition, this confirms that the function we calculated as solves the backward PDE generated by the GBM diffusion. This is the expectation-to-PDE side of our Feynman-Kac example all done.
Before turning to the dual, forward picture, let us see why the backward PDE produces a conditional expectation.
The Martingale Step: Applying Ito’s Lemma to the PDE Solution
We have checked that satisfies the backward PDE, but there is still the question: why does the PDE lead to a conditional expectation?
To see this, let’s apply Ito’s lemma to , where
follows our GBM:
The expression multiplying is exactly the left-hand side of the backward Kolmogorov equation. We have just shown that it is zero, hence,
We can see this cancellation using our Ito layer built earlier in this series:
du = ito_lemma_1d( f=u, t=t, x=x, dX=gbm.dX(), dw_index=gbm.dw_index)print("Itô differential of u(t, X_t):")print(du)show_eq(r"\text{drift}[du]", du.b)show_eq(r"\text{diffusion}[du]", du.c[gbm.dw_index])assert du.b.simplify_full() == 0
And the drift simplifies to zero as expected!
The remaining diffusion coefficient is
The zero drift tells us that , for
, is a local martingale. To use its conditional expectation property, we also need it to be a true martingale. In this GBM example,
is proportional to
, and GBM has a finite fourth moment throughout the finite interval
, thus it is square-integrable and we can write:
At the terminal time, . Hence
This gives us the mechanism behind the expectation-to-PDE connection: the backward equation cancels the drift in Ito’s lemma, and the martingale property carries the terminal value back to time .
We have now completed the observable side of the picture. Next, we will turn to its dual: evolve the transition density forward using the adjoint , then integrate
against that density to recover the same
.
Following the Dual Side
So far we have followed the observable backward from its terminal value.
This time, we keep the starting state fixed and follow the distribution of future states forward.
Let be the time elapsed since
, and let
denote a possible value of
. The GBM transition formula tells us that
Consequently, the transition density is also well-known:
Here is a parameter specifying where the process started and
is the state variable in the forward equation.
Now let’s create this density in SageMath:
s, y = var("s y")assume(x > 0, y > 0, s > 0, sigma > 0)log_return = log(y/x) - (mu - sigma**2/2)*sp = exp(-log_return**2/(2*sigma**2*s)) / ( y*sigma*sqrt(2*pi*s))show_eq(r"p(s,y\mid x)", p)
The above generator we know acts on functions, and its formal adjoint $\mathcal{L}^*$ acts on densities. For GBM, we already know that the forward Kolmogorov equation is
Then, our existing forward_kolmogorov() functions computes the residual $p_s – \mathcal{L}^*[p]$:
from Ito.kolmogorov import forward_kolmogorovforward_residual = forward_kolmogorov( p=p, t=s, state_vars=y, drift=mu*y, diffusion=sigma*y, simplify_result=True).simplify_full()show_eq(r"p_s-\mathcal{L}^{*}p", forward_residual)assert forward_residual == 0
The above zero residual confirms that the density evolves forward according to the adjoint of the same generator we used on the backward side. Its initial condition is understood as a limit, i.e., as decreases to zero, the distribution concentrates at
, becoming a point mass there.
Bridging the Gap with the Handshake
We can now do the calculation promised at the start of the post. At elapsed time , average our observable
against the density:
To evaluate the integral, use the change of variable
Under this substitution, becomes the standard normal density
, while
The integral is therefore a normal exponential moment. Using the known identity: , we obtain
We can check that this result agrees with the conditional expectation constructed earlier:
density_moment = ( x**2 * exp(2*(mu - sigma**2/2)*s) * exp((2*sigma*sqrt(s))**2/2)).simplify_full()backward_value = u.subs(t=T-s)show_eq(r"\int_0^\infty y^2p(s,y\mid x)\,dy", density_moment)show_eq(r"u(T-s,x)", backward_value)handshake_residual = (density_moment - backward_value).simplify_full()show_eq(r"\text{difference}", handshake_residual)assert handshake_residual == 0

There is our long-awaited handshake! The difference is zero.
SageMath has confirmed that our analytically evaluated density integral equals the solution of the backward Kolmogorov equation (PDE). The forward equation evolves the density forward from the starting state, whilst the backward equation evolves the observable function backward from its terminal value. Pairing the density with that observable brings the two sides together:
Conclusion
We started with a question about a future observation: if geometric Brownian motion is at today, what is the expected value of
at the terminal boundary? The exact GBM transition gave us a (solution) function
, and our SageMath calculations showed that this function satisfies the backward Kolmogorov equation and the terminal condition
.
In the middle, we used Ito’s lemma and explained the link between those calculations. When we evaluate along a path of the diffusion, the drift coefficient of
is just the differential operator
. We verified that this expression is zero. In this example
is a true martingale, so its value at time
is the conditional expectation of its value at the later time
. The terminal condition then turns that future value into
.
Finally, we also reached the answer from the other direction. Starting at , the (already known) lognormal transition d0ensity evolves forward according to the adjoint generator
. Averaging
against that density gives exactly the same solution
!. The backward PDE follows the observable; the forward PDE follows the probabilities. Their pairing is the conditional expectation we set out to calculate.
That is the Feynman–Kac handshake made concrete in this simple example.
backward_value = u.subs(t=T-s)handshake_residual = (density_moment - backward_value).simplify_full()assert handshake_residual == 0
Our Ito and Sde Python Layer
The SageMath calculations are possible because of our Ito and Sde Python layers we built throughout this series. The class object SDE1D holds the GBM coefficients and applies its generator; backward_kolmogorov() and forward_kolmogorov() turn the generator and its adjoint into PDE residuals; and ito_lemma_1d() exposes the drift and diffusion terms when we need to evaluate along the process. SageMath then simplifies each expression so we can check the identities. The layers let us carry the same diffusion through the probabilistic and PDE calculations without redefining its dynamics at every step.
Why is it Useful to Have the Dual Representation?
In the end, we often have these types of equations: conditional expectations:
and this is often very hard to calculate. But now we know that this is only one way to look at the solution. Feynman-Kac gives us an alternative route: solve the backward PDE with terminal boundary condition .
But it’s useful in both directions.
If the expectation is difficult but the PDE is tractable, then solve the PDE (usually numerically).
If the PDE is difficult, simulate paths of and estimate the expectation by Monte Carlo.
In either case, the result is a function that you can evaluate across current states and times. This GBM example was unusually nice and well behaved because both routes have a closed form solution, which makes it super easy to evaluate and use the zero residual metric to show both sides are equivalent.



