Skip to content

Commit d708103

Browse files
committed
Working on Chapter 8
1 parent 747b853 commit d708103

5 files changed

Lines changed: 347 additions & 7 deletions

File tree

book/book.tex

Lines changed: 347 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3860,9 +3860,7 @@ \section{Data}
38603860
df = pd.read_csv('glucose_insulin.csv', index_col='time')
38613861
\end{python}
38623862

3863-
\py{df} has two columns: \py{glucose} is the concentration of blood glucose in \si{\milli\gram/\deci l}; \py{insulin} is concentration of insulin in the blood in \si{\micro U\per\milli l}
3864-
3865-
The index is time in \si{\minute}.
3863+
\py{df} has two columns: \py{glucose} is the concentration of blood glucose in \si{\milli\gram/\deci l}; \py{insulin} is concentration of insulin in the blood in \si{\micro U\per\milli l}. The index is time in \si{\minute}.
38663864

38673865
\begin{figure}
38683866
\centerline{\includegraphics[height=3in]{figs/chap08-fig01.pdf}}
@@ -3872,23 +3870,365 @@ \section{Data}
38723870

38733871
Figure~\ref{chap08-fig01} shows glucose and insulin concentrations over \SI{182}{\minute} for a subject with normal insulin production and sensitivity.
38743872

3873+
%TODO: consider siunitx settings for \per (as a fraction or negative power).
3874+
38753875

38763876

38773877
\section{Interpolation}
38783878

3879-
Before we are ready to implement the model, there's one problem we have to solve. In the differential equations, $I$ is a function that can be evaluated at any time, $t$.
3879+
Before we are ready to implement the model, there's one problem we have to solve. In the differential equations, $I$ is a function that can be evaluated at any time, $t$. But in the \py{DataFrame}, we only have measurements at discreet times. This is a job for interpolation!
38803880

3881-
We are treating $I(t)$ as an input to the model
3881+
\py{modsim.py} provides a function named \py{interpolate}, which is a wrapper for \py{scipy.interpolate.interp1d}. It takes any kind of \py{Series} as a parameter, including \py{TimeSeries} and \py{Sweep}, and returns a function. That's right, I said it returns a {\em function}.
38823882

3883+
So we can call it like this:
38833884

3885+
\begin{python}
3886+
I = interpolate(df.insulin)
3887+
\end{python}
38843888

3889+
And now we can call the new function, \py{I}, like this:
38853890

3886-
\section{Implementation}
3891+
\begin{python}
3892+
I(18)
3893+
\end{python}
3894+
3895+
The result is 31.66, which is a linear interpolation between the actual measurements at \py{t=16} and \py{t=19}. We can also run \py{I} with an array:
3896+
3897+
\begin{python}
3898+
ts = arange(0, 182, 2)
3899+
I(ts)
3900+
\end{python}
3901+
3902+
The result is an array of interpolated values for equally-spaced values of \py{t} between 0 and 182.
3903+
3904+
\py{interpolate} can take additional options as parameters, which it passed along to \py{scipy.interpolate.interp1d}. You can read about these options at \url{https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.interp1d.html}.
3905+
3906+
3907+
3908+
\section{Implementating the model}
3909+
3910+
To get started, we'll assume that the parameters of the model are known. We'll implement the model and use it to generate time series for \py{G} and \py{X}. Then we'll see how to find parameter values that generate series that best fit the data.
3911+
3912+
Taking advantage of estimates from prior work, I'll start with these values:
3913+
3914+
\begin{python}
3915+
k1 = 0.03
3916+
k2 = 0.02
3917+
k3 = 1e-05
3918+
G0 = 290
3919+
\end{python}
3920+
3921+
And I'll use the measurements at \py{t=0} as the basal levels:
3922+
3923+
\begin{python}
3924+
Gb = df.glucose[0]
3925+
Ib = df.insulin[0]
3926+
\end{python}
3927+
3928+
Now we can create the initial state:
3929+
3930+
\begin{python}
3931+
init = State(G=G0, X=0)
3932+
\end{python}
3933+
3934+
And the \py{System} object:
3935+
3936+
\begin{python}
3937+
system = System(init=init,
3938+
k1=k1, k2=k2, k3=k3,
3939+
I=I, Gb=Gb, Ib=Ib,
3940+
t0=0, t_end=182, dt=2)
3941+
\end{python}
3942+
3943+
%TODO: Consider making System take a string argument like
3944+
% System('init k1 k2 k3...')
3945+
3946+
Now here's the update function:
3947+
3948+
\begin{python}
3949+
def update_func(state, t, system):
3950+
G, X = state
3951+
unpack(system)
3952+
3953+
dGdt = -k1 * (G - Gb) - X*G
3954+
dXdt = k3 * (I(t) - Ib) - k2 * X
3955+
3956+
G += dGdt * dt
3957+
X += dXdt * dt
3958+
3959+
return State(G=G, X=X)
3960+
\end{python}
3961+
3962+
As usual, the parameters include a \py{State} object with the current state and a \py{System} object with the system parameters. But there's one difference from previous examples: this update function also takes \py{t} as a parameter, because this system of differential equations is {\bf time-dependent}; that is, time appears in the right-hand side of at least one equation. Specifically, we have to evaluate \py{I} at \py{t}.
3963+
3964+
The first line uses multiple assignment to extract the current values of \py{G} and \py{X}. The second line uses \py{unpack} to make the system variables available as if they were global variables, as we saw in Section~\ref{xxx}. %TODO: add that section
3965+
3966+
Computing the derivatives \py{dGdt} and \py{dXdt} is straightforward; we just have to translate the equations from math notation to Python.
3967+
3968+
Then, to perform the update, we multiply each derivative by the discrete time step \py{dt}, which is \SI{2}{\minute} in this example. The return value is a \py{State} object with the new values of \py{G} and \py{X}.
3969+
3970+
Before running the simulation, it is always a good idea to run the update function with the initial conditions:
3971+
3972+
\begin{python}
3973+
update_func(init, 0, system)
3974+
\end{python}
3975+
3976+
If there are no errors, and the results seem reasonable, we are ready to run the simulation. Here's one more version of \py{run_simulation}. It is almost the same as in Section~\ref{xxx}, with one change: it passes \py{t} as a parameter to \py{update_func}.
3977+
3978+
%TODO: Make this version consistent with Chapter 7
3979+
3980+
\begin{python}
3981+
def run_simulation(system, update_func):
3982+
unpack(system)
3983+
3984+
df = TimeFrame(columns=init.index)
3985+
df.loc[t0] = init
3986+
3987+
for t in arange(t0, t_end, dt):
3988+
df.loc[t+dt] = update_func(df.loc[t], t, system)
3989+
3990+
system.results = df
3991+
\end{python}
3992+
3993+
And we can run it like this:
3994+
3995+
\begin{python}
3996+
run_simulation(system, update_func)
3997+
\end{python}
3998+
3999+
\begin{figure}
4000+
\centerline{\includegraphics[height=3in]{figs/chap08-fig03.pdf}}
4001+
\caption{Results from simulation of the glucose minimal model.}
4002+
\label{chap08-fig03}
4003+
\end{figure}
4004+
4005+
The results are shown in Figure~\ref{chap08-fig03}. The top plot shows simulated glucose levels from the model along with the measured data. The bottom plot shows simulated insulin levels in tissue fluid, which is in unspecified units, and not to be confused with measured concentration of insulin in the blood.
4006+
4007+
With the parameters I chose, the model fits the data reasonably well. We can do better, but first, I want to replace \py{run_simulation} with a better differential equation solver.
4008+
4009+
4010+
\section{Numerical solution of differential equations}
4011+
\label{slopefunc}
4012+
4013+
So far we have been solving differential equations by rewriting them as difference equations. In the current example, the differential equations are:
4014+
%
4015+
\[ \frac{dG}{dt} = -k_1 \left[ G(t) - G_b \right] - X(t) G(t) \]
4016+
%
4017+
\[ \frac{dX}{dt} = k_3 \left[I(t) - I_b \right] - k_2 X(t) \]
4018+
%
4019+
If we multiply both sides by $dt$, we have:
4020+
%
4021+
\[ dG = dt \left[ -k_1 \left[ G(t) - G_b \right] - X(t) G(t) \right] \]
4022+
%
4023+
\[ dX = dt \left[ k_3 \left[I(t) - I_b \right] - k_2 X(t) \right] \]
4024+
%
4025+
When $dt$ is very small, or more precisely infinitesimal, this equation is exact. But in our simulations, $dt$ is \SI{2}{\minute}, which is small but not infinitesimal. In effect, the simulations assume that the derivatives $dG/dt$ and $dX/dt$ are constant during each \SI{2}{\minute} time step. That's not exactly true, but it can be a good enough approximation.
4026+
4027+
This method, evaluating derivatives at discrete time steps and assuming that they are constant in between, is called {\bf Euler's method} (see \url{https://en.wikipedia.org/wiki/Euler_method}).
4028+
4029+
Euler's method can be good enough for some simple problems, but there are many better ways to solve differential equations, including an entire family of methods called linear multistep methods (see \url{https://en.wikipedia.org/wiki/Linear_multistep_method}).
4030+
4031+
Rather than implement these methods ourselves, we will use functions from SciPy. \py{modsim.py} provides a function called \py{run_odeint}, which is a wrapper for \py{scipy.integrate.odeint}. The name \py{odeint} stands for ``ordinary differential equation integrator". The equations we are solving are ``ordinary'' because all the derivatives are with respect to the same variable, time in this case; there are no partial derivatives. And the solver is called an integrator because solving differential equations is considered a form of integration.
4032+
4033+
\py{scipy.integrate.odeint} is a wrapper for \py{LSODA}, which is from ODEPACK, a venerable collection of ODE solvers written in Fortran (for some of the history of ODEPACK, see \url{http://history.siam.org/oralhistories/hindmarsh.htm}).
4034+
4035+
To use \py{odeint}, we have to provide a ``slope function":
4036+
4037+
\begin{python}
4038+
def slope_func(state, t, system):
4039+
G, X = state
4040+
unpack(system)
4041+
4042+
dGdt = -k1 * (G - Gb) - X*G
4043+
dXdt = k3 * (I(t) - Ib) - k2 * X
4044+
4045+
return dGdt, dXdt
4046+
\end{python}
4047+
4048+
\py{slope_func} is similar to \py{update_func}; in fact, it takes the same parameters. But \py{slope_func} is simpler, because all we have to do is compute the derivatives, that is, the slopes. We don't have to do the updates; \py{odeint} does them for us.
4049+
4050+
Before we call \py{run_odeint}, we have to create a \py{System} object:
4051+
4052+
\begin{python}
4053+
system2 = System(init=init,
4054+
k1=k1, k2=k2, k3=k3,
4055+
I=I, Gb=Gb, Ib=Ib,
4056+
ts=df.index)
4057+
\end{python}
4058+
4059+
When we were using \py{run_simulation}, we created a \py{System} object with variables \py{t0}, \py{t_end}, and \py{dt}. When we use \py{run_odeint}, we don't need those variables, but we do have to provide \py{ts}, which is an array or \py{Series} that contains the times where we want the solver to evaluate $G$ and $X$.
4060+
4061+
Now we can call \py{run_odeint} like this:
4062+
4063+
\begin{python}
4064+
run_odeint(system2, slope_func)
4065+
\end{python}
4066+
4067+
Like \py{run_simulation}, \py{run_odeint} puts the results in a \py{DataFrame} and stores it as a system variable named \py{results}. The columns of \py{results} match the state variables in \py{init}. The index of results match the values from \py{ts}; in this example, \py{ts} contains the timestamps of the measurements.
4068+
4069+
The results are similar to what we saw in Figure~\ref{chap08-fig03}. The difference is about 1\% on average and never more than 2\%.
4070+
4071+
4072+
\section{Optimization}
4073+
4074+
So far we have been taking the parameters as given, but in general we don't have that luxury. Normally we are given the data and we have to search for the parameters that yield a time series that best matches the data.
4075+
4076+
We will do that now, in two steps:
4077+
4078+
\begin{enumerate}
4079+
4080+
\item First we'll define an {\bf error function} that takes a set of possible parameters, simulates the system with the given parameters, and computes the difference between the simulation results and the data.
4081+
4082+
\item Then we'll use a library function from SciPy, \py{leastsq}, to search for the parameters that minimize the mean squared errors (MSE).
4083+
4084+
\end{enumerate}
4085+
4086+
Here's an outline of the functions we'll use:
4087+
4088+
\begin{itemize}
4089+
4090+
\item \py{modsim.py} provides \py{fit_leastsq}, which takes a function called \py{error_func} as a parameter. It does some error-checking, then calls \py{scipy.optimize.leastsq}, which does the real work.
4091+
4092+
\item \py{scipy.optimize.leastsq} uses functions called \py{lmdif} and \py{lmdir}, which implement the Levenberg-Marquardt algorithm for non-linear least squares problems (see \url{https://en.wikipedia.org/wiki/Levenberg-Marquardt_algorithm}). These functions are provided by another venerable FORTRAN library called MINPACK (see \url{https://en.wikipedia.org/wiki/MINPACK}).
4093+
4094+
\item When \py{scipy.optimize.leastsq} runs, it calls \py{error_func} many times, each time with a different set of parameters, until it converges on the set of parameters that minimizes MSE.
4095+
4096+
\end{itemize}
4097+
4098+
Each time the error function runs, it creates a \py{System} object with the given parameters, so let's wrap that operation in a function:
4099+
4100+
\begin{python}
4101+
def make_system(G0, k1, k2, k3, data):
4102+
init = State(G=G0, X=0)
4103+
system = System(init=init,
4104+
k1=k1, k2=k2, k3=k3,
4105+
Gb=Gb, Ib=Ib,
4106+
I=interpolate(data.insulin),
4107+
ts=data.index)
4108+
return system
4109+
\end{python}
4110+
4111+
\py{make_system} takes \py{G0} and the rate constants as parameters, as well as \py{data}, which is the \py{DataFrame} containing the measurements. It creates and returns a \py{System} object.
4112+
4113+
Now here's the error function:
4114+
4115+
\begin{python}
4116+
def error_func(params, data):
4117+
system = make_system(*params, data)
4118+
run_odeint(system, slope_func)
4119+
error = system.results.G - data.glucose
4120+
return error
4121+
\end{python}
4122+
4123+
The parameters of \py{error_func} are
4124+
4125+
\begin{itemize}
4126+
4127+
\item \py{params}, which is a sequence of four system parameters, and
4128+
4129+
\item \py{data}, which is the \py{DataFrame} containing the measurements.
4130+
4131+
\end{itemize}
4132+
4133+
It uses \py{make_system} to create the \py{System} object. This line demonstrates a feature we have not seen before, the {\bf scatter operator}, \py{*}. Applied to \py{params}, the scatter operator unpacks the sequence, so instead of being considered a single value, it is treated as four separate values.
4134+
4135+
\py{error_func} calls \py{run_odeint} using the same slope function we saw in Section~\ref{slopefunc}. Then it computes the difference between the simulation results and the data. Since \py{system.results.G} and \py{data.glucose} are both \py{Series} objects, the result of subtraction is also a \py{Series}.
4136+
4137+
Now to do the actual minimization, we run \py{fit_leastsq}:
4138+
4139+
\begin{python}
4140+
k1 = 0.03
4141+
k2 = 0.02
4142+
k3 = 1e-05
4143+
G0 = 290
4144+
params = G0, k1, k2, k3
4145+
best_params = fit_leastsq(error_func, params, df)
4146+
\end{python}
4147+
4148+
\py{error_func} is the function we just defined. \py{params} is a sequence containing an initial guess for the four system parameters. And \py{df} is the \py{DataFrame} containing the measurements.
4149+
4150+
Actually, the third parameter can be any object we like. \py{fit_leastsq} and \py{leastsq} don't do anything with this parameter except to pass it along to \py{error_func}, so in general it contains whatever information \py{error_func} needs to do its job.
4151+
4152+
4153+
\section{Interpreting parameters}
4154+
4155+
The return value from \py{fit_leastsq} is \py{best_params}, which we can pass along to \py{make_system}, again using the scatter operator, and then run the simulation:
4156+
4157+
\begin{python}
4158+
system = make_system(*best_params, df)
4159+
run_odeint(system, slope_func)
4160+
\end{python}
4161+
4162+
4163+
\begin{figure}
4164+
\centerline{\includegraphics[height=3in]{figs/chap08-fig04.pdf}}
4165+
\caption{Simulation of the glucose minimal model with parameters that minimize MSE.}
4166+
\label{chap08-fig04}
4167+
\end{figure}
4168+
4169+
Figure~\ref{chap08-fig04} shows the results. The simulation matches the measurements well, except during the first few minutes after the injection. But we don't expect the model to do well in this regime.
4170+
4171+
The reason is that the model is {\bf non-spatial}; that is, it does not take into account differences in concentrations in different places in the body. Instead, it assumes that the concentration of glucose and insulin in blood, and insulin in tissue fluid, is the same throughout the body.
4172+
4173+
Immediately after injection, it takes time for the injected glucose to circulate. During that time, we don't expect a non-spatial model to be accurate. For this reason, we should not take the estimated value of \py{G0} too seriously; it is useful for fitting the model, but not meant to correspond to a physical, measurable quantity.
4174+
4175+
On the other hand, the other parameters are meaningful; in fact, they are the reason the model is useful. Using the best-fit parameters, we can estimate two quantities of interest:
4176+
4177+
\begin{itemize}
4178+
4179+
\item ``Glucose effectiveness", which is the tendency of elevated glucose to cause depletion of glucose.
4180+
4181+
\item ``Insulin sensitivity", which is the ability of elevated blood insulin to enhance glucose effectiveness.
4182+
4183+
\end{itemize}
4184+
4185+
We can use the differential equations to compute these quantities.
4186+
%
4187+
\[ \frac{dG}{dt} = -k_1 \left[ G(t) - G_b \right] - X(t) G(t) \]
4188+
%
4189+
\[ \frac{dX}{dt} = k_3 \left[I(t) - I_b \right] - k_2 X(t) \]
4190+
%
4191+
Glucose effectiveness is defined as the change in $dG/dt$ as we vary $G$:
4192+
%
4193+
\[ E \equiv - \frac{\delta \dot{G}}{\delta G} \]
4194+
%
4195+
where $\dot{G}$ is shorthand for $dG/dt$. Taking the derivative of $dG/dt$ with respect to $G$, we get
4196+
%
4197+
\[ E = k_1 + X \]
4198+
%
4199+
The glucose effectiveness index, $S_G$, is defined to be the value of $E$ in when blood insulin is near its basal level, $I_b$. In that case, $X$ approaches 0 and $E$ approaches $k_1$. So we can use the best-fit value of $k_1$ as an estimate of $S_G$.
4200+
4201+
The insulin sensitivity index, $S_I$, is defined to be the value of $S$ when $E$ and $I$ are at steady state:
4202+
%
4203+
\[ S_I \equiv \frac{\delta E_{SS}}{\delta I_{SS}} \]
4204+
%
4205+
$E$ and $I$ are at steady state when $dG/dt$ and $dX/dt$ are 0, but we don't actually have to solve those equations to find $S_I$.
4206+
4207+
If we set $dX/dt = 0$ and solve for $X$, we find the relation:
4208+
%
4209+
\[ X_{SS} = \frac{k_3}{k_2} I_{SS} \]
4210+
%
4211+
And since $E = k_1 + X$, we have:
4212+
%
4213+
\[ S_I = \frac{\delta E_{SS}}{\delta I_{SS}} = \frac{\delta X_{SS}}{\delta I_{SS}} \]
4214+
%
4215+
Taking the derivative of $X_{SS}$ with respect to $I_{SS}$, we have:
4216+
%
4217+
\[ S_I = k3 / k2 \]
4218+
%
4219+
So if we find parameters that make the model fit the data, we can use $k_3 / k_2$ as an estimate of $S_I$.
4220+
4221+
For the example data, the estimated values of $S_G$ and $S_I$ are $0.029$ and for $8.9 \times 10^{-4}$.
4222+
4223+
Normal?
4224+
4225+
Units?
38874226

3888-
Based on previous examples, translating these differential equations into code is straightforward.
38894227

38904228

4229+
\section{The insulin minimal model}
38914230

4231+
Introduce the exercise
38924232

38934233

38944234

book/figs/chap08-fig01.pdf

855 Bytes
Binary file not shown.

book/figs/chap08-fig02.pdf

19.3 KB
Binary file not shown.

book/figs/chap08-fig03.pdf

17.8 KB
Binary file not shown.

book/figs/chap08-fig04.pdf

15.1 KB
Binary file not shown.

0 commit comments

Comments
 (0)