Stochastic Differential Equation: Ion channel noise in Hodgkin-Huxley neurons

Download this notebook here: Stochastic Differential Equation: Ion channel noise in Hodgkin-Huxley neurons

In the following, we estimate parameters of the stochastic differential equation model of ion channel noise in Hodgkin-Huxley neurons presented in:

Goldwyn, Joshua H., Nikita S. Imennov, Michael Famulare, and Eric Shea-Brown. “Stochastic Differential Equation Models for Ion Channel Noise in Hodgkin-Huxley Neurons.” Physical Review E 83, no. 4 (2011): 041908. doi:10.1103/PhysRevE.83.041908.

The code was implemented in Fortran 95 and made available in ModelDB: ModelDB.

(The code is not included in pyABC and neither developed nor maintained by the pyABC developers.)

Download and compilation of the Fortran model

We start by downloading the code from ModelDB. For this, the requests package is needed.

In [1]:
import requests
URL = ("https://senselab.med.yale.edu/modeldb/"
       "eavBinDown.cshtml?o=128502&a=23&mime=application/zip")
req = requests.request("GET", URL)

The zip file to which URL points is stored in memory. The code is then extracted into a temporary directory and compiled using make HH_run provided as part of the download from ModelDB. The Fortran compiler gfortran is required for compilation.

In [2]:
import os
from zipfile import ZipFile
import subprocess
import tempfile
from io import BytesIO

tempdir = tempfile.mkdtemp()
archive = ZipFile(BytesIO(req.content))
archive.extractall(tempdir)
ret = subprocess.run(
        ["make", "HH_run"],
        cwd=os.path.join(tempdir, "ModelDBFolder"))
EXEC = os.path.join(tempdir, "ModelDBFolder", "HH_run")
print(f"The executable location is {EXEC}")
The executable location is /tmp/tmpujmwnetn/ModelDBFolder/HH_run

The variable EXEC points to the executable.

A simulate function is defined which uses the subprocess.run function to execute the external binary. The external binary writes to stdout. The output is captured and stored in a pandas dataframe. This dataframe is returned by the simulate function.

In [3]:
import pandas as pd
import numpy as np

def simulate(model=2, membrane_dim=10, time_steps=1e4,
             time_step_size=0.01, isi=100, dc=20, noise=0,
             sine_amplitude=0, sine_frequency=0,
             voltage_clamp=0, data_to_print=1, rng_seed=None):
    """
    Simulate the SDE Ion Channel Model defined
    in an external Fortran simulator.

    Returns: pandas.DataFrame
        Index: t, Time
        Columns: V, Na, K
        V: Voltage
        Na, K: Proportion of open channels
    """
    if rng_seed is None:
        rng_seed = np.random.randint(2**32-2) + 1
    membrane_area = membrane_dim**2
    re = subprocess.run(
        [EXEC, str(model),
         # the binary cannot very long floats
         f"{membrane_area:.5f}", str(time_steps),
         str(time_step_size), str(isi), f"{dc:.5f}",
         str(noise), str(sine_amplitude), str(sine_frequency),
         str(voltage_clamp), str(data_to_print),
         str(rng_seed)],
         stdout=subprocess.PIPE)
    df = pd.read_table(BytesIO(re.stdout),
                       delim_whitespace=True,
                       header=None, index_col=0,
                       names=["t", "V", "Na", "K"])
    return df

Generating the observed data

We run a simulations and plot the fraction of open “K” channels and open “Na” channels:

In [4]:
import matplotlib.pyplot as plt
%matplotlib inline

gt = {"dc": 20, "membrane_dim": 10}
fig, axes = plt.subplots(nrows=2, sharex=True)
fig.set_size_inches((12,8))
for _ in range(10):
    observation = simulate(**gt)
    observation.plot(y="K", color="C1", ax=axes[0]);
    observation.plot(y="Na", color="C0", ax=axes[1]);
for ax in axes:
    ax.legend().set_visible(False)
axes[0].set_title("K")
axes[0].set_ylabel("K")
axes[1].set_title("Na")
axes[1].set_ylabel("Na");
../_images/examples_sde_ion_channels_11_0.png

We observe how the channels open and close and also that the individual trajectores differ from realization to realization, even though we simulate for the exact same parameter set. We take the last simulation as observed data.

Defining distance and prior

We’ll now demonstrate how to use pyABC to estimate parameters of the model. Here, we’ll focus on the dc and the membrane_dim parameters. The dc parameter describes the input current, the membrane_dim is the square root of the membrane area. We choose uniform priors:

In [5]:
from pyabc import Distribution, RV, ABCSMC

dcmin, dcmax = 2, 30
memmin, memmax = 1, 12
prior = Distribution(
    dc=RV("uniform", dcmin, dcmax - dcmin),
    membrane_dim=RV("uniform", memmin, memmax - memmin))

The distance function is defined as \(L_2\) norm between the fractions of open “K” channels.

In [6]:
def distance(x, y):
    diff = x["data"]["K"] - y["data"]["K"]
    dist = np.sqrt(np.sum(diff**2))
    return dist

We also define a small simulate_pyabc wrapper function, which wraps the simulate function. This is needed to comply with the interface expected by pyABC.

In [7]:
def simulate_pyabc(parameter):
    res = simulate(**parameter)
    return {"data": res}

Performing parameter inference with pyABC

We are now ready to start pyABC. As usually, we first initialize the ABCSMC object, then pass the observed data and the database location in which to store the logged parameters and summary statistics, and finally run the inference until the maximum number of allowed populations max_nr_populations or the final acceptance threshold minimum_epsilon is reached.

In [8]:
abc = ABCSMC(simulate_pyabc, prior, distance,
             population_size=35)
abc_id = abc.new("sqlite:///"
                 + os.path.join(tempdir, "test.db"),
                 {"data": observation})
history = abc.run(max_nr_populations=10, minimum_epsilon=6)
INFO:Epsilon:initial epsilon is 12.418323389587156
INFO:History:Start <ABCSMC(id=1, start_time=2017-09-20 11:42:03.976982, end_time=None)>
INFO:ABC:t:0 eps:12.4183233896
INFO:ABC:t:1 eps:11.537720558193579
INFO:ABC:t:2 eps:10.331234246232354
INFO:ABC:t:3 eps:9.138290891195336
INFO:ABC:t:4 eps:8.27383912256212
INFO:ABC:t:5 eps:7.693082673497323
INFO:ABC:t:6 eps:6.781590876632613
INFO:ABC:t:7 eps:6.073019960463141
INFO:ABC:t:8 eps:5.570268139592767
INFO:History:Done <ABCSMC(id=1, start_time=2017-09-20 11:42:03.976982, end_time=2017-09-20 12:37:07.851393)>

Visualization of the estimated parameters

We plot the posterior distribution after a few generations together with the parameters generating the observed data (the dotted line and the orange dot).

In [9]:
from pyabc.visualization import plot_kde_matrix

dfw = history.get_distribution(m=0)
grid = plot_kde_matrix(*dfw,
                limits={"dc": (dcmin, dcmax),
                        "membrane_dim": (memmin, memmax)})
grid.map_diag(lambda x, **kwargs: plt.gca().axvline(
    gt[x.name], color="k", linestyle="dotted"))
grid.map_lower(lambda x, y, **kwargs: plt.gca().scatter(
    [gt[x.name]], [gt[y.name]], color="orange"))
plt.gcf().set_size_inches(8, 8)
../_images/examples_sde_ion_channels_25_0.png

The dc parameter is very well detected. (Don’t get confused by the y-axis. It applies to the scatterplot, not to the marginal distribution.) The distribution of membrane_dim is broader. (Note that even the exact posterior is not necessarily peaked at the ground truth parameters).

Evaluation of the fit

We compare four types of data:

  1. samples from the prior distribution,
  2. samples from the posterior distribution,
  3. the data generated by the accepted parameters, and
  4. the observation.
In [10]:
from pyabc.transition import MultivariateNormalTransition

fig, axes = plt.subplots(nrows=3, sharex=True)
fig.set_size_inches(8, 12)
n = 5  # Number of samples to plot from each category
# Plot samples from the prior
alpha = .5
for _ in range(n):
    prior_sample = simulate(**prior.rvs())
    prior_sample.plot(y="K", ax=axes[0],
                      color="C1", alpha=alpha)


# Fit a posterior KDE and plot samples form it
posterior = MultivariateNormalTransition()
posterior.fit(*history.get_distribution(m=0))

for _ in range(n):
    posterior_sample = simulate(**posterior.rvs())
    posterior_sample.plot(y="K", ax=axes[1],
                          color="C0", alpha=alpha)


# Plot the stored summary statistics
sum_stats = history.get_sum_stats(t=history.max_t, m=0)
for stored in sum_stats[1][:n]:
    stored["data"].plot(y="K", ax=axes[2],
                        color="C2", alpha=alpha)


# Plot the observation
for ax in axes:
    observation.plot(y="K", ax=ax, color="k", linewidth=1.5)
    ax.legend().set_visible(False)
    ax.set_ylabel("K");

# Add a legend with pseudo artists to first plot
axes[0].legend([plt.plot([0], color="C1")[0],
           plt.plot([0], color="C0")[0],
           plt.plot([0], color="C2")[0],
           plt.plot([0], color="k")[0]],
          ["Prior", "Posterior",
           "Stored, accepted", "Observation"],
         bbox_to_anchor=(.5, 1),
         loc="lower center",
         ncol=4);
../_images/examples_sde_ion_channels_29_0.png

We observe that the samples from the prior exhibit the largest variation and do not resemble the observation well. The samples from the posterior are much closer to the observed data. Even a little bit closer are the samples generated by the accepted parameters. This has at least two reasons: First, the posterior KDE-fit smoothes the particle populations. Second, the sample generated by a parameter that was accepted is biased towards being more similar to the observed data as compared to a random sample from that parameter.