20. Lateral Inhibition: Spontaneous symmetry breaking to form a spatial pattern of cells


Design principle

  • Local interactions between cells can lead to globally-structured spatial patterns

  • cis-inhibition in the Notch pathway accelerates pattern formation

  • The instability of a homogenous steady state is a requirement for spontaneous pattern formation

Techniques

  • Modeling a spatial grid of interacting cells

  • Simulating and visualizing ODE systems on a grid

References


[1]:
# Colab setup ------------------
import os, sys, subprocess
if "google.colab" in sys.modules:
    cmd = "pip install --upgrade biocircuits colorcet watermark"
    process = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, stderr = process.communicate()
# ------------------------------

import numpy as np
import scipy.integrate
import scipy.optimize
import random

import biocircuits

import bokeh.plotting
import bokeh.io
from bokeh.models import HexTile, MultiLine, Line, Text, Plot, ColorBar, LogColorMapper, ColumnDataSource

import colorcet

import matplotlib
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
%matplotlib inline
%config InlineBackend.figure_format = "retina"

# Set to True to have fully interactive plots with Python;
# Set to False to use pre-built JavaScript-based plots
interactive_python_plots = False
notebook_url = "localhost:8888"

bokeh.io.output_notebook()
Loading BokehJS ...

Spatial patterns are an integral part of development

In Chapter 14, we discussed the role of cellular differentiation in many different cell types as a necessary part of the developmental process by which a single cell becomes a complex multicellular organism. But a variety of cell types alone is not sufficient to constitute a living, functional organism—it is also essential that these cell types be spatially organized into body plans and morphologies.

Think about your own spatial morphology—each of us is (mostly) bilaterally symmetric, with a head, arms, and legs that branch off from our major axis in consistent ways. Zooming into a finer level of detail, the hair cells on your skin are spaced out at fairly regular intervals that vary in overall density from place to place on your body. Looking inwards, our body also has a consistent topological organization, with our skin encompassing our skeleton and interior organs, each of which also has various outer and interior layers that are structurally and functionally distinct. How do cells interact with one another to consistently and precisely self-organize in this way? Can we use our circuits-based framework to try and understand aspects of these processes?

In this chapter we will study a single, cell-level spatial process called lateral inhibition patterning, a “motif” in spatial pattern generation. We will discuss how simple interaction rules betweeen neighboring cells can spontaneously and robustly generate a well-defined spatial pattern, in this case a checkerboard pattern where cells adopt one of two distinct states.

Cell-level checkerboard patterning is seen in development

We will work with a minimal conceptual model of lateral inhibtion patterning that has the following properties: 1. The final pattern will be a checkerboard pattern of alternating cell types with fine-grain (close to single-cell) spatial resolution. 2. The initial state of the pattern is a plane of genetically identical cells, where each cell has an equal potential of becoming one or the other final cell type. 3. The pattern forms ‘by itself’ without requiring external signal cues. This is called spontaneous symmetry breaking.

Over time, the process of pattern formation will look something like the schematic below, where each hexagon in the grid represents a single cell.

The first question we might ask is whether this pattern is actually relevant in known developmental systems. Indeed, there are many situations in which cell-level checkerboard patterning has been observed—the two images below are examples from sensory cell development in the inner ear of a developing chick (left) and hair cells from the dorsal thorax of a fruit fly (right). In both cases, patterning defects have functional consequences for the organism, either through impaired hearing in the ear example or through altered aerodynamic properties that could affect flight in the hair cell example. (At the same time, it is important to emphasize that natural spatial patterning processes, such as the generation of a checkerboard array of sound-sensing hairs in the inner ear, are more complex, than the minimal lateral inhibition models we consider below.)

Biological examples of LI patterning

Images from Goodyear and Richardson 1997, J. Neurosci. (left), Lu and Adler 2015, PLoS One (right)

The Notch-Delta pathway allows neighboring cells to communicate with each other

How do these patterns arise? Given that the resolution of the pattern is at the level of individual neighboring cells, it is likely that the process that generates the pattern would require some kind of information transfer between neighboring cells as well. This is called juxtacrine signaling, when cells can directly communicate with their adjacent neighbors but not with cells that are farther away.

A ubiquitous example of juxtacrine signaling (and one that is involved in the above inner-ear and hair cell examples) is the Notch-Delta pathway. Ligands, such as Delta, and receptors, such as Notch, are both single-pass transmembrane proteins expressed on the surface of cells. Notch on one cell can bind to its ligand Delta on an adjacent cell. Subsequently, a mechanical force generated by endocytosis of the ligand leads to the cleavage of the Notch receptor, and the release of its intracellular domain (ICD) within the cell. Once released, the ICD translocates to the nucleus, where it activates transcription of Notch pathway target genes.

A model of Notch signaling enables lateral inhibition patterning

Decades of research by geneticists and developmental biologists have revealed a powerful, conserved role for Notch signaling in a diverse range of spatial pattering processes, including those described above. In fact, the ability of mutations in Notch ligands and receptors to disrupt pattern formation in specific ways is responsible for their names: Notch mutations produce notches in fly wings, while Delta mutations lead to expansion of wing veins to produce formations resembling a river delta. Such genetic studies, together with many other approaches, have suggested circuit-level mechanisms through which Notch signaling enables patterning in various specific contexts. However, mathematical models are required to understand how these patterning circuits could work, and how they could fail.

Sprinzak et al. (2011, PLoS Computational Biology) developed a mathematical model of lateral inhibition patterning through Notch-type signaling. As with previous models, they focused on an intercellular positive feedback loop that occurs if Notch signaling leads to down-regulation of Delta expression. In this case, more signaling in one cell is assumed to reduce the amount of ligand it produces, thereby diminishing signaling to its neighbors. Uninhibited by signaling, the neighbors can express even more ligand, reinforcing the high signaling level of the initial cell. This is diagrammed below by imagining a smaller system of just two interacting cells. The intercellular positive feedback loop is a bit like the toggle switch we encountered earlier. This time, however, it couples cells together, so that a two-cell system can go either to a state in which one cell or the other makes ligand, but not both.

Two_cell_lateral_inhibition_feedback.png

We now extrapolate similar dynamics to a two-dimensional field of cells, in which each cell begins with a roughly equal concentration of Notch and Delta on its membrane. We will see that this system can reach a stable patterned state, in which cells with high Delta are surrounded by cells with low Delta expression. Note, however, that the persistence of the pattern relies on continuous signaling between the cells—if you could remove a low-Delta cell from the pattern and isolate it, without neighbors, it would cease receiving signals, and therefore re-express Delta.

ODE models can explicitly acknowledge individual cells

How would we go about modeling this system? Unlike the models we have analyzed so far in this course, this system requires us to keep track of the concentrations of Notch and Delta in every individual cell, and only allows interactions between Notch and Delta molecules that belong to neighboring cells. Thankfully, the mathematical framework of ODEs can still incorporate this extra information. One simply needs to make a much larger system of ODEs, in which each cell’s protein concentrations are given their own differential equation, and all concentrations can evolve over time individually.

We will begin with the molecular binding reactions involved in our model. When a Notch receptor on a cell \(i\) interacts with a Delta ligand on a neighboring cell \(j\), the Notch and Delta undergo a reversible binding reaction between their free states and the bound complex \(T_{ij}\). From this bound state, the intracellular domain of Notch can be cleaved, which leads to the formation of the signal species \(S_i\) inside the receiver cell. Coincident with this event, both the remainder of the Notch receptor and the Delta ligand on cell are withdrawn back inside the sender cell, so that they no longer exist on the cell membrane and therefore cannot interact with other species. The reaction system governing this process is therefore

\begin{align} N_i + D_j \rightleftharpoons T_{ij} \rightarrow S_i, \end{align}

where we will say that the formation and disassociation of \(T_{ij}\) occurs with rate constants \(k_D^+\) and \(k_D^-\), respectively, and that the irreversible formation of \(S_i\) occurs with the rate constant \(k_S\).

How does Notch signaling control Delta? We will assume that \(S_i\) acts as a transcriptional activator of a secondary intracellular regulatory molecule \(R_i\) that can, in turn regulate genes such as Delta. This is based on the fact that Notch is known to directly up-regulate a core set of transcription factors that do in fact regulate a larger set of other genes. To complete the intercellular feedback loop, we assume that \(R_i\) represses expression of Delta within cell \(i\).

Using the above assumptions, we can now write out a full ODE system. For simplicity, we will first consider a case where there are ownly two cells, indexed by \(i\) and \(j\). Then our ODE system for the species in cell \(i\) would be

\begin{align} \frac{\mathrm{d}N_i}{\mathrm{d}t} &= \beta_N - \gamma_N N_i - \left( k_D^+ N_iD_j - k_D^- T_{ij}\right) \\ \frac{\mathrm{d}D_i}{\mathrm{d}t} &= \beta_D \frac{1}{1 + (R_i/K_D)^{n_D}} - \gamma_D D_i - \left( k_D^+ N_jD_i - k_D^- T_{ji}\right) \\ \frac{\mathrm{d}T_{ij}}{\mathrm{d}t} &= k_D^+ N_iD_j - k_D^- T_{ij} - k_S T_{ij} \\ \frac{\mathrm{d}S_i}{\mathrm{d}t} &= k_S T_{ij} - \gamma_S S_i \\ \frac{\mathrm{d}R_i}{\mathrm{d}t} &= \beta_R \frac{(S_i/K_R)^{n_R}}{1 + (S_i/K_R)^{n_R}} - \gamma_R R_i. \end{align}

There are also a set of five ODEs describing the species \(N_j,D_j, T_{ji}, S_j, R_j\) in the other cell that follow this same structure, for a total of ten ODEs in the system.

We will now make a quasi-steady-state assumption that the binding and cleavage events involved in the formation of \(T_{ij}\) and \(S_i\) are fast enough to be at equilbirium at the timescale relevant to the gene expression dynamics driving changes in the concentrations of \(N_i,D_i,R_i\). This means that

\begin{align} T_{ij} &= \frac{k_D^+ N_iD_j}{k_D^- + k_S}, \\ S_i &= \frac{k_S}{\gamma_S} T_{ij} = \frac{1}{\gamma_S}\frac{k_S k_D^+}{k_D^- + k_S} N_iD_j \end{align}

We can define \(k_t \equiv (k_D^- + k_S)/k_S k_D^+\) for notational simplicity and substitute the above expressions into our original ODE system to obtain

\begin{align} \frac{\mathrm{d}N_i}{\mathrm{d}t} &= \beta_N - \gamma_N N_i - \frac{N_i D_j}{k_t} \\ \frac{\mathrm{d}D_i}{\mathrm{d}t} &= \beta_D \frac{1}{1 + (R_i/K_D)^{n_D}} - \gamma_D D_i - \frac{D_i N_j}{k_t} \\ \frac{\mathrm{d}R_i}{\mathrm{d}t} &= \beta_R \frac{\left(\frac{N_i D_j}{\gamma_S k_t K_R}\right)^{n_R}}{1 + \left(\frac{N_i D_j}{\gamma_S k_t K_R}\right)^{n_R}} - \gamma_R R_i \end{align}

In order to reduce the parameter complexity of the model, we will further assume that Notch and Delta are degraded at similar rates (\(\gamma \equiv \gamma_N = \gamma_D\)) and then nondimensionalize the system with \(\tilde{t} \leftarrow \gamma_R t\), \(\tilde{N_i} \leftarrow N_i/\gamma k_t\), \(\tilde{D_i} \leftarrow D_i/\gamma k_t\), \(\tilde{R_i} \leftarrow R_i/K_D\), to obtain the nondimensional system (with tildes dropped)

\begin{align} \tau \frac{\mathrm{d}N_i}{\mathrm{d}t} &= \beta_N - N_i - N_i D_j \\ \tau \frac{\mathrm{d}D_i}{\mathrm{d}t} &= \beta_D \frac{1}{1 + R_i^{n_D}} - D_i - N_j D_i \\ \frac{\mathrm{d}R_i}{\mathrm{d}t} &= \beta_R \frac{\left(N_i D_j / k_{RS}\right)^{n_R}}{1 +\left(N_i D_j / k_{RS}\right)^{n_R} } - R_i \end{align}

where we have defined new nondimensional parameters \(\tau \equiv \gamma_R/\gamma\), \(\tilde{\beta_N} \equiv \beta_N / \gamma^2 k_t\), \(\tilde{\beta_D} \equiv \beta_D/\gamma^2 k_t\), \(\tilde{\beta_R} \equiv \beta_R/\gamma_R K_D\), and \(k_{RS} \equiv K_R \gamma_S / \gamma^2 k_t\).

Note that this is a system of six ODEs, with an equivalent set of three ODEs for \(N_j\), \(D_j\), and \(R_j\) that follow the above format.

Now that we have have written down our model, let’s solve it to see how it performs. Since we can write down all the equations manually, we can use the standard techniques for numerical solution of a system of ODEs that we have covered previously in the course.

[2]:
def LI_2cell_rhs(x, t, betaN, betaD, betaR, nD, nR, kRS, tau):
    Ni, Di, Ri, Nj, Dj, Rj = x

    return np.array(
        [
            (betaN - Ni - Ni*Dj)/tau,
            (betaD/(1+Ri**nD) - Di - Nj*Di)/tau,
            betaR*(Ni*Dj)**nR / (kRS**nR + (Ni*Dj)**nR) - Ri,
            (betaN - Nj - Nj*Di)/tau,
            (betaD/(1+Rj**nD) - Dj - Ni*Dj)/tau,
            betaR*(Nj*Di)**nR / (kRS**nR + (Nj*Di)**nR) - Rj,
        ]
    )

# Parameters (from Sprinzak et al)
betaN = 10
betaD = 100
betaR = 1e6
nD = 1
nR = 3
kRS = 3e5 ** (1/nR)
tau = 1

# initial conditions
Ni0_slider = bokeh.models.Slider(title="Log Initial Ni", start=-3, end=3, step=0.1, value=1)
Di0_slider = bokeh.models.Slider(title="Log Initial Di", start=-3, end=3, step=0.1, value=-3)
Nj0_slider = bokeh.models.Slider(title="Log Initial Nj", start=-3, end=3, step=0.1, value=1)
Dj0_slider = bokeh.models.Slider(title="Log Initial Dj", start=-3, end=3, step=0.1, value=-2.9)
nR_slider = bokeh.models.Slider(title="Cooperativity (nR)", start=0.1, end=10, step=0.1, value=3)

x0 = [10**Ni0_slider.value,
      10**Di0_slider.value,
      0,
      10**Nj0_slider.value,
      10**Dj0_slider.value,
      0]

# Solve ODEs
t = np.linspace(0, 20, 1000)
args = (betaN, betaD, betaR, nD, nR_slider.value, kRS, tau)
x = scipy.integrate.odeint(LI_2cell_rhs, x0, t, args=args)
x = x.transpose()

cds = bokeh.models.ColumnDataSource(data=dict(t=t, Ni=x[0,:], Di=x[1,:], Ri=x[2,:],
                                              Nj=x[3,:], Dj=x[4,:], Rj=x[5,:]))

# Set up the plots
pN = bokeh.plotting.figure(
    frame_width=275, frame_height=150,
    x_axis_label="dimensionless time",
    y_axis_label="dimensionless\nNotch concentration", y_axis_type="log",
)
pD = bokeh.plotting.figure(
    frame_width=275, frame_height=150,
    x_axis_label="dimensionless time",
    y_axis_label="dimensionless\nDelta concentration", y_axis_type="log",
)
pR = bokeh.plotting.figure(
    frame_width=275, frame_height=150,
    x_axis_label="dimensionless time",
    y_axis_label="dimensionless\nReporter concentration", y_axis_type="log",
)

pN.line(source=cds, x="t", y="Ni", color="blue", width=3, legend_label="Cell i")
pN.line(source=cds, x="t", y="Nj", color="orange", width=3, legend_label="Cell j")
pD.line(source=cds, x="t", y="Di", color="blue", width=3, legend_label="Cell i")
pD.line(source=cds, x="t", y="Dj", color="orange", width=3, legend_label="Cell j")
pR.line(source=cds, x="t", y="Ri", color="blue", width=3, legend_label="Cell i")
pR.line(source=cds, x="t", y="Rj", color="orange", width=3, legend_label="Cell j")

LI_2cell_layout = bokeh.layouts.row(
    bokeh.layouts.column(
        pN,
        pD,
        pR,
    ),
    bokeh.layouts.column(
        Ni0_slider,
        Di0_slider,
        Nj0_slider,
        Dj0_slider,
        nR_slider,
        width=150,
    )
)

# Display
if interactive_python_plots:
    # Set up callbacks
    def _callback(attr, old, new):
        x0 = [10**Ni0_slider.value,
              10**Di0_slider.value,
              0,
              10**Nj0_slider.value,
              10**Dj0_slider.value,
              0]
        args = (betaN, betaD, betaR, nD, nR_slider.value, kRS, tau)
        x = scipy.integrate.odeint(LI_2cell_rhs, x0, t, args=args)
        x = x.transpose()
        cds.data = dict(t=t, Ni=x[0,:], Di=x[1,:], Ri=x[2,:], Nj=x[3,:], Dj=x[4,:], Rj=x[5,:])

    Ni0_slider.on_change("value", _callback)
    Di0_slider.on_change("value", _callback)
    Nj0_slider.on_change("value", _callback)
    Dj0_slider.on_change("value", _callback)
    nR_slider.on_change("value", _callback)

    # Build the app
    def LI_2cell_app(doc):
        doc.add_root(LI_2cell_layout)

    bokeh.io.show(LI_2cell_app, notebook_url=notebook_url)
else:
    Ni0_slider.disabled = True
    Di0_slider.disabled = True
    Nj0_slider.disabled = True
    Dj0_slider.disabled = True
    nR_slider.disabled = True

    # Build layout
    LI_2cell_layout = bokeh.layouts.row(
    bokeh.layouts.column(
        pN,
        pD,
        pR,
    ),
    bokeh.layouts.column(
    bokeh.layouts.column(
        Ni0_slider,
        Di0_slider,
        Nj0_slider,
        Dj0_slider,
        width=150
    ),
        bokeh.models.Div(
                            text="""
        <p>Sliders are inactive. To get active sliders, re-run notebook with
        <font style="font-family:monospace;">fully_interactive_plots = True</font>
        in the first code cell.</p>
                """,
                            width=250,
                        ),
            )
        )


    bokeh.io.show(LI_2cell_layout)

As we can see, in a two-cell system these dynamics (in this particular parameter regime) lead to a divergence in the states of the two cells. When the initial Notch concentrations of the two cells are equal, this divergence follows a “winner-takes-all” process where the cell that starts with a higher Delta concentration ends up in the high-Delta state. But by using the sliders to alter the intial values of the Notch concentration as well, you can see that the full relationship is a bit more subtle.

Is it always guaranteed that the two cells will end up in divergent cell states? No— try moving around the slider for the \(n_R\) parameter to see how the system behaves both in regions of low cooperativity and in regimes of high cooperativity. We can see that having the cells the cells go to diverging states, which is a prerequisite for patterning, is not necessarily a guarantee in our system.

Expanding beyond a two-cell system

Although the two-cell model is a good demonstration that our dynamics can, in principle, lead to diverging cell fates, we are fundamentally interested in cellular patterns that can expand over a spatial area that involves more than just two cells. In order to expand our model to accomdate larger systems, we need to incorporate the possibility of a given cell having more than one neighbor. A given Notch receptor \(N_i\) will have the ability to interact with multiple possible Delta ligands \(D_j\) to form a variety of distinct \(T_{ij}\) complexes, all of which will contribute to the formation of \(S_i\) within the receiver cell. We therefore need to update our steady-state expression for \(S_i\) to

\begin{align} S_i = \frac{k_S}{\gamma_S} \sum_{j=]i[ } T_{ij} = \frac{1}{\gamma_S}\frac{k_S k_D^+}{k_D^- + k_S}\sum_{j=]i[ } N_iD_j. \end{align}

Here, the notation \(\sum_{j = ]i[}\) means the index \(j\) is iterated over all cells that are adjacent to cell \(i\) (but not including \(i\) itself), and the resulting set is summed together.

Carrying this through the simplifcations and nondimensionalizations given above, we obtain the following nondimensionalized ODE system to accomodate arbitray spatial arrangements of cells:

\begin{align} \tau \frac{\mathrm{d}N_i}{\mathrm{d}t} &= \beta_N - N_i - \sum_{j = ]i[} N_i D_j \\ \tau \frac{\mathrm{d}D_i}{\mathrm{d}t} &= \beta_D \frac{1}{1 + R_i^{n_D}} - D_i - \sum_{j = ]i[} N_j D_i \\ \frac{\mathrm{d}R_i}{\mathrm{d}t} &= \beta_R \frac{\left(\sum_{j = ]i[} N_i D_j / k_{RS}\right)^{n_R}}{1 +\left(\sum_{j = ]i[} N_i D_j / k_{RS}\right)^{n_R} } - R_i \end{align}

Note the way that the summations affect the \(T_{ij}\) formation terms in the \(N_i\) and \(D_i\) ODEs. Although the expressions now look quite a bit more complicated with all these sums, we did not add any new parameters our system— visually comparing the above ODE system with the previous ODE system for the two-cell system should reveal how the addition of additional neighbors did not change the fundamental nature of a given species’ dynamics.

We now note two features of this model: * First, it can represent the behavior any number of cells by expanding the size of the ODE system— a system of \(i\) cells can be modeled by \(3i\) equations following the above formula. While such a system would be impractical to solve by hand, using numerical approaches we can solve large ODE systems without much additional difficulty. * Second, the model can represent arbitrary geometric arrangements of cells because the assignment of neighbors to each cell is left arbitrary. Thus we could easily switch between, for example, a square or hexagonal lattice, or even the dimensionality of the space, by changing the neighbor-assignment rule for a given cell. However, the fact that the ODEs do not natively encode the neighbor-assignment rule means that we will have to write an additional function to go inside our ODE solver that identifies the apporpriate neighbors for a given cell.

Specifying the neighbor-assignment rules

Let’s first expand our two-cell system to a one-dimensional line of cells, where each cell would have just neighbors. In this arrangement we can easily write down a rule for determining the neighbors of a given cell \(i\). Since a single index fully determines the location of a cell, we can know right away that the neighbors of cell \(i\) are cell \(i-1\) and \(i+1\). But remember that we are only simulating a finite number of cells, say \(N\) of them. What happens to this rule when we are looking at the leftmost or rightmost cell? Cell \(0\) has no leftward neighbor, as Cell \(-1\) does not exist— similarly, cell \(N-1\) has no rightward neighbor, as cell \(N\) does not exist (this is because our first cell is indexed as cell \(0\), so the highest index for a group of \(N\) cells is \(N-1\)). So what is the right way to handle these situations?

There actually isn’t a single right answer that will apply to every situation— different choices of how to handle the boundaries could be equally valid depending on the situation being modeled. A few different choices are schematized in the figure below.

schematics of some boundary conditions

A constant boundary condition might be appropriate when you are interested in modeling the dynamics of the system as it interacts explicitly with an edge, such as a population of a different cell type. If these cell types don’t participate in the modeled dynamics, then one can set the constant value of the boundary to zero to obtain the special case of having an inert boundary. A periodic boundary condition, while it mathematically represents the grid as curving around to loop back on itself, as if it were on the surface of a sphere, can also be justified to approximate the case where the grid of cells being modeled is a small subset in the interior of a large field of identical cells.

Thus for a system of ODEs solved over a spatially-explicit arrangement of cells, we need to specify the following for the problem to be complete: 1. The system’s dynamics within an individual cell (the ODEs) 2. The arrangement of the cells in space, including special rules for the boundaries (the Neighbor Rule)

Numerical solution of the Lateral Inhibition model on a 2D hexagonal lattice

Let’s now consider how we would numerically solve our ODE system on a 2D lattice of hexagonal cells. We choose this particular spatial arrangement because it mirrors how cells are packed in the fruit fly wing. Because the number of equations in the system varies with the number of cells, we cannot hard-code a function that manually writes out every ODE in our system. This would not only be cumbersome, but we would have to write a new ODE function every time we wanted to change the number of cells we are simulating. We will instead write a helper function that our ODE function can call to generate the required neighbor information for an arbitrary cell.

How do we define neighbors in a hexagonal grid? It turns out that a 2-dimensional indexing can fully define the location of a cell in a hexagonal grid, just as it can for a square grid. While in a square grid the four neighbors of cell \((i,j)\) would be the cells \((i+1,j)\), \((i,j-1)\), \((i-1,j)\), and \((i,j+1)\), in a hexagonal grid the six neighbors of cell \((i,j)\) are \((i+1,j)\), \((i+1,j-1)\), \((i,j-1)\), \((i-1,j)\), \((i-1,j+1)\), and \((i,j+1)\). Using this rule we can therefore define a helper function that, given the system’s species concentration vector \(\boldsymbol{x}\) and the cell indices \(i,j\), return the sum of the concentrations of a particular species in all of the neighbors of cell \((i,j)\).

Coordinates for a hexagonal grid

However, our function is not yet complete because we have not specified our boundary conditions. Here we will choose to use periodic boundary conditions, whicih means that in an \(N\times N\) grid of cells, the upper-right-most cell \((N-1,N-1)\) is neighbors with the lower-left-most cell \((0,0)\). While it is possible to hard-code all the edge cases and modify the neighbor rule accordingly within the helper function, doing so would be extremely cumbersome. Instead we will take a more general approach, which is to use modular arthithmetic.

While standard arithmetic performs algebraic operations like addition and multiplication along the real number line, modular arithmetic can be conceptualized as performing these same operations along a circular number line that behaves like a clock. On the face of a 12-hour clock, adding 2 hours to 11:00 doesn’t give 13:00, but rather 1:00. This is equivalent to performing the \(11+2\) operation modulo \(12\), which gives the result of \(1\).

Modular arithmetic

If we apply this concept to our 2D hexagonal grid of cells, we can see that if we perform all of our addition and subtraction operations from our neighbor rule modulo \(N\), then we don’t need to write special cases for our periodic boundary conditions. \(N-1 + 1 = 0\), and \(0 - 1 = N-1\). In Python you can perform modular arithmetic by performing the standard operation and doing the modulo division operation on the modulus number, which is represented by the symbol %. So if you wanted to code the operation \(2 + 3\) modulo \(4\), then you would write (2+3) % 4.


We have now specified all of the properties required to fully determine the dynamics of our system, but we still need to consider one more thing before we can calculate our numerical solution. In the grid mindset above, we are conceptualizing our cellular grid here as a 2-dimensional grid, our ODE solver scipy.integrate.odeint() can only take a 1-dimensional vector as an argument. This is a common theme that will arise as you use computational methods for your research— you will find that tools and packages developed by others may not interface with your particular problem in exactly the right way, so you will have to do some work to bridge the gap.

In this case, we will resolve this issue by converting our concentration array back and forth between a one-dimensional form and a three-dimensional form (two spatial dimensions and a third dimension to hold the 3 species concentrations for a given cell) using the np.reshape() operation. This allows us get the best of both worlds by working with the more intuitive multi-dimensional form of the array whenever we are intializing the system or calculating neighbors, but still enabling us to use the powerful performance of scipy.integrate.odeint().

We are now ready to code up our ODE system for a 2D lattice and solve it! We do so below for a \(12\times 12\) grid, intializing the system at a homogeneous state where all of the cells have the same concentrations of every species, but then adding some independent normally-distributed noise around the nonzero concentrations such that the magnitude of the noise tend to be within 10% of the mean value.

The cell below will take a few seconds to run.

[3]:
# Write helper functions
def get_Djs_hex_periodic(x3, i, j):
    r = x3.shape[0]
    return x3[(i+1)%r,j,1] + x3[(i+1)%r,(j-1)%r,1] + x3[i,(j-1)%r,1] + x3[(i-1)%r,j,1] + x3[(i-1)%r,(j+1)%r,1] + x3[i,(j+1)%r,1]

def get_Njs_hex_periodic(x3, i, j):
    r = x3.shape[0]
    return x3[(i+1)%r,j,0] + x3[(i+1)%r,(j-1)%r,0] + x3[i,(j-1)%r,0] + x3[(i-1)%r,j,0] + x3[(i-1)%r,(j+1)%r,0] + x3[i,(j+1)%r,0]

# Write ODE rhs function
def LI_hex_periodic_rhs(x, t, betaN, betaD, betaR, nD, nR, kRS, tau):
    """
    Assumes a square NxN grid of cells with hexagonal tiling
    and periodic boundary conditions.
    """
    # confirm that the reshape procedure worked properly
    assert len(x) % 3 == 0
    numcells = int(len(x)/3)
    numrows = np.sqrt(numcells)
    assert numrows % 1 == 0
    numrows = int(numrows)

    # Reshape the concentration array to 3D
    x3 = x.reshape((numrows,numrows,3))
    dxdt_3 = np.empty((numrows, numrows, 3))

    for i in range(numrows):
        for j in range(numrows):
            Ni = x3[i,j,0]
            Di = x3[i,j,1]
            Ri = x3[i,j,2]
            Djs = get_Djs_hex_periodic(x3, i, j)
            Njs = get_Njs_hex_periodic(x3, i, j)

            dxdt_3[i,j,0] = (betaN - Ni - Ni*Djs)/tau
            dxdt_3[i,j,1] = (betaD/(1+Ri**nD) - Di - Njs*Di)/tau
            dxdt_3[i,j,2] = betaR*(Ni*Djs)**nR / (kRS**nR + (Ni*Djs)**nR) - Ri

    # Reshape output array to 1D
    dxdt = dxdt_3.reshape(numrows*numrows*3,)

    return dxdt

# Parameters
betaN = 10
betaD = 100
betaR = 1e6
nD = 1
nR = 3
kRS = 3e5 ** (1/nR)
tau = 1

numrows = 12

# Define initial conditions
Ni0 = 10
Di0 = 1e-3
Ri0 = 0
# Define noise around the initial condition
Ni0_std = Ni0 / 10 / 2 # 98% of samples will be within 10% of Ni0
Di0_std = Di0 / 10 / 2 # 98% of samples will be within 10% of Di0

x0_3 = np.empty((numrows,numrows,3))
for i in range(numrows):
    for j in range(numrows):
        x0_3[i,j,0] = np.random.normal(Ni0, Ni0_std)
        x0_3[i,j,1] = np.random.normal(Di0, Di0_std)
        x0_3[i,j,2] = 0
x0 = x0_3.reshape(numrows*numrows*3,)

# Solve ODEs
tmax = 30
numpoints = 1000

t = np.linspace(0, tmax, numpoints)
args = (betaN, betaD, betaR, nD, nR, kRS, tau)
x = scipy.integrate.odeint(LI_hex_periodic_rhs, x0, t, args=args)
x = x.transpose()

# Reshape output array to convenient shape
x4 = x.reshape((numrows,numrows,3,1000))
[4]:
# Set up plots
pT = bokeh.plotting.figure(
    frame_width=375, frame_height=200,
    x_axis_label="dimensionless time",
    y_axis_label="dimensionless\nconcentration", y_axis_type="log",
    x_range = [0, tmax], y_range=[1e-4,1e4],
    title="Lateral Inhibition, 12x12 periodic hexagonal grid"
)
pND = bokeh.plotting.figure(
    frame_width=250, frame_height=250,
    x_axis_label="dimensionless\nNotch concentration", x_axis_type="log",
    y_axis_label="dimensionless\nDelta concentration", y_axis_type="log",
    x_range=[2e-2, 2e1], y_range=[4e-4, 1e2]
)

# Slider to control time
time_slider = bokeh.models.Slider(title="time index", start=0, end=numpoints-1, step=1, value=650)

# Set up MultiLine Glyph
NDsource = ColumnDataSource(dict(
        ts = list(np.tile(t[:time_slider.value], (numrows*numrows,1))),
        Ns = list(np.reshape(x4[:,:,0,:time_slider.value], (numrows*numrows, time_slider.value))),
        Ds = list(np.reshape(x4[:,:,1,:time_slider.value], (numrows*numrows, time_slider.value))),
        Rs = list(np.reshape(x4[:,:,2,:time_slider.value], (numrows*numrows, time_slider.value))),
    )
)
Tsource = ColumnDataSource(dict(
        ts = list(np.tile(t[:], (numrows*numrows,1))),
        Ns = list(np.reshape(x4[:,:,0,:], (numrows*numrows, numpoints))),
        Ds = list(np.reshape(x4[:,:,1,:], (numrows*numrows, numpoints))),
        Rs = list(np.reshape(x4[:,:,2,:], (numrows*numrows, numpoints))),
    )
)
t_cds = ColumnDataSource(dict(curr_t = np.repeat(t[time_slider.value - 1], 2), y=np.array([1e-4,1e4])))

N_glyph = MultiLine(xs="ts", ys="Ns", line_color="blue", line_width=2, line_alpha=.2, syncable=False)
D_glyph = MultiLine(xs="ts", ys="Ds", line_color="red", line_width=2, line_alpha=.2, syncable=False)
R_glyph = MultiLine(xs="ts", ys="Rs", line_color="green", line_width=2, line_alpha=.2, syncable=False)
pT.add_glyph(Tsource, N_glyph)
pT.add_glyph(Tsource, D_glyph)
pT.add_glyph(Tsource, R_glyph)

ND_glyph = MultiLine(xs="Ns", ys="Ds", line_color="black", line_width=2, line_alpha=0.2)
pND.add_glyph(NDsource, ND_glyph)
T_glyph = Line(x="curr_t", y="y", line_color="black", line_width=2)
pT.add_glyph(t_cds, T_glyph)


# Display
if interactive_python_plots:

    # Set up callbacks
    def _callback(attr, old, new):
        t_ls = np.tile(t[:time_slider.value], (numrows*numrows,1))
        N_ls = np.reshape(x4[:,:,0,:time_slider.value], (numrows*numrows, time_slider.value))
        D_ls = np.reshape(x4[:,:,1,:time_slider.value], (numrows*numrows, time_slider.value))
        NDsource.data = dict(
                            ts = list(t_ls),
                            Ns = list(N_ls),
                            Ds = list(D_ls),
                            # Rs = list(R_ls),
                        )
        t_cds.data = dict(dict(curr_t = np.repeat(t[time_slider.value -1], 2), y=np.array([1e-4,1e4])))


    time_slider.on_change("value_throttled", _callback)

    LI_hex_tcourse_layout = bokeh.layouts.row(
        bokeh.layouts.column(
            pT,
            time_slider,
        ),
        pND
    )

    # Build the app
    def LI_hex_tcourse_app(doc):
        doc.add_root(LI_hex_tcourse_layout)


    bokeh.io.show(LI_hex_tcourse_app, notebook_url=notebook_url)
else:

    time_slider.disabled=True

    LI_hex_tcourse_layout = bokeh.layouts.row(
    bokeh.layouts.column(
        pT,
        time_slider,
    ),
    bokeh.layouts.column(
        pND,
        bokeh.models.Div(
                            text="""
        <p>Sliders are inactive. To get active sliders, re-run notebook with
        <font style="font-family:monospace;">fully_interactive_plots = True</font>
        in the first code cell.</p>
                """,
                            width=250,
                        ),
        )
    )

    bokeh.io.show(LI_hex_tcourse_layout)

From the above plot we can see that the intially nearly-homogeneous population of cells diverges into two distinct states. But a plot like the one above doesn’t tell us abot the spatial nature of the pattern, so we can’t know if our system has successfully formed a Lateral Inhibition pattern or not based on this information alone.

How can we visualize the spatial arrangement of the cells? We will use Bokeh’s HexTile attribute to plot out a \(12\times 12\) grid of hexagonal tiles, and then map the \(R_i\) values that we obtained from our above simulation onto a color value for each tile. We can then add in a slider to let us view how the pattern changes over time.

[5]:
# Widgets for controlling time
time_slider = bokeh.models.Slider(title="time index", start=0, end=numpoints-1, step=1, value=numpoints-1)

# extract Ri timecourse solutions
xR = x4[:,:,2,:]
Rmin = np.ma.masked_array(xR, mask=xR==0).min() # smallest nonzero value in xR
Rmax = xR.max()
color_mapper = LogColorMapper(palette="Viridis256", low=Rmin, high=Rmax)

xx, yy = np.meshgrid(np.arange(numrows), np.arange(numrows))
xx = xx.flatten()
yy = yy.flatten()

Rvals = np.empty(len(xx))
for i in range(len(Rvals)):
    Rvals[i] = xR[xx[i], yy[i], time_slider.value]
colors = matplotlib.cm.viridis(Rvals/Rmax)

phex = Plot(
    title='R(t) in each cell, Lateral Inhibition Model', width=450, height=300,
    min_border=0,
    toolbar_location=None
)

source = ColumnDataSource(dict(
        q=xx,
        r=yy,
        colors=colors,
    )
)

glyph = HexTile(q="q", r="r", size=1, fill_color="colors", line_color="white")
phex.add_glyph(source, glyph)

# text = Text(x = 12, y = 12, text="curr_t")
# phex.add_glyph(source, text)

color_bar = ColorBar(color_mapper=color_mapper, label_standoff=12)

phex.add_layout(color_bar, 'right')

# Display
if interactive_python_plots:

    # Set up callbacks
    def _callback(attr, old, new):
        Rvals = np.empty(len(xx))
        for i in range(len(Rvals)):
            Rvals[i] = xR[xx[i], yy[i], time_slider.value]
        colors = matplotlib.cm.viridis(Rvals/Rmax)
        source.data = dict(q=xx, r=yy, colors=colors)

    time_slider.on_change("value", _callback)

    # Build layout
    hex_layout = bokeh.layouts.column(
        phex,
        bokeh.layouts.Spacer(height=10),
        time_slider
    )

    # Build the app
    def hex_app(doc):
        doc.add_root(hex_layout)

    bokeh.io.show(hex_app, notebook_url=notebook_url)
else:
    time_slider.disabled=True

    hex_layout = bokeh.layouts.column(
        phex,
        bokeh.layouts.Spacer(height=10),
        time_slider,
        bokeh.models.Div(
                            text="""
        <p>Sliders are inactive. To get active sliders, re-run notebook with
        <font style="font-family:monospace;">fully_interactive_plots = True</font>
        in the first code cell.</p>
                """,
                            width=250,
                        ),
    )


    bokeh.io.show(hex_layout)

From the above visualization, we can see that the grid is large enough that the pattern initially forms ‘independently’ at several nucleation points and begins to spread out. When two wavefronts collide, we see that the value of \(R_i\) briefly increases at the collision boundary before settling back down to its steady state value.

The Notch pathway exhibits cis-inhibition

While the above model shows how a generic juxtacrine signaling system with properties similar to the Notch pathway can indeed generate a lateral inhibition pattern, the actual Notch pathway has an additional feature that was not considered in the above model, which was discovered by Sprinzak et al. (2010, Nature). This is the property of cis-inhibition, where Notch receptors and Delta ligands on the surface of the same cell can bind to each other, preventing them from binding in trans to receptors and ligands on neighboring cells.

Schematic of cis-inhibition

Notch signaling acts in a walkie-talkie regime under strong cis-inhibition

Sprinzak et al. immediately realized that cis-inhibition is an example of molecular sequestration, which we know from Chapter 12 can generate threshold-linear responses. When this principle is applied to the Notch pathway, that means that cells will, in the limit of strong cis-inhibition, exist exclusively in sending-only or receiving-only states, without being able to do both actions. This is because sending and receiving require the presence of free Delta ligands and Notch receptors, respectively, to exist on the cell surface— if cis-inhibition is sufficiently strong, then all of the ligands and receptors on the cell surface will bind into inactive cis-complex forms. The formation of these cis-complexes will be limited by whichever type has a lower concentration, so the cell can only have free ligands if they are in excess of receptors (or vice versa).

Schematic of walkie talkie

Given that molecular titration leads to sharp transitions in cellular behavior (in this case, whether the cell is exclusively a sender or exclusively a receiver), it makes intuitive sense that the Notch pathway, which facilitates fine-resolution spatial patterning in development, would exhibit this feature. But does the presence of cis-inhibition actually enhance the formation and robustness of known patterns such as lateral inhibition? And if so, in what ways does this architecture contribute to the patterning propreties?

A model of lateral inhibition with cis-inhibition

Sprinzak et al. set out to investigate this question by asking what the addition of cis-inhibition to the lateral inhibition model described above would do to the resulting chekerboard pattern and its properties. In order to include cis-inhibition, they added the additional complexing reaction where the notch receptor \(N_i\) and the delta ligand \(D_i\) of the same cell can bind together to form an inactive complex \(C_{ii}\). This complex also eventually undergoes an irreversible endocytosis into the cell, removing it and its monomers from the membrane and preventing them from interacting with other molecules in the system. However, this endocytosis does not lead to the cleavage of the Notch intracellular domain and therefore does not generate a signal molecule \(S_i\). The reactions involved in cis-inhibition are therefore

\begin{align} N_i + D_i \rightleftharpoons C_{ii} \rightarrow \emptyset, \end{align}

and we will say that the binding reactions proceed with rate constants \(k_C^+\) for the complexing reaction and \(k_C^-\) for the dissociation reaction, and that the endocytosis of the inactive complex occurs with rate constant \(\gamma_{ND}\). If, as before, we apply the quasi-steady-state reaction to the binding and endocytosis reactions, then we can substitute the equilbirium relation \(C_{ii} = k_C^+ N_i D_i / (k_C^- + \gamma_{ND})\) into the ODE system whereever it apppears. Sprinzak et al. additionally made the assumption that the cis-binding of Notch and Delta is irreversible (i.e. \(k_C^- = 0\)).

Following the same assumptions and nondimensionalization process as in our earlier derivation, we will eventually obtain the nondimensional ODE system

\begin{align} \tau \frac{\mathrm{d}N_i}{\mathrm{d}t} &= \beta_N - N_i - \sum_{j = ]i[} N_i D_j - \frac{N_iD_i}{\kappa_C}\\ \tau \frac{\mathrm{d}D_i}{\mathrm{d}t} &= \beta_D \frac{1}{1 + R_i^{n_D}} - D_i - \sum_{j = ]i[} N_j D_i - \frac{N_iD_i}{\kappa_C}\\ \frac{\mathrm{d}R_i}{\mathrm{d}t} &= \beta_R \frac{\left(\sum_{j = ]i[} N_i D_j / k_{RS}\right)^{n_R}}{1 +\left(\sum_{j = ]i[} N_i D_j / k_{RS}\right)^{n_R} } - R_i \end{align}

which has the additional parameter \(\kappa_C \equiv \gamma_{ND}/k_C^+ k_t\).

cis-inhibition speeds up lateral inhibition pattern formation

This new model formulation allows us to take our existing lateral inhibition model and study the impact of adding cis-inhibition using only a single new parameter, \(\kappa_C\). Sprinzak et al simulated this additional model and found that the speed with which the lateral inhibition pattern formed was greatly increased by the addition of the cis-inhibition reactions.

[6]:
def LIMI_hex_periodic_rhs(x, t, betaN, betaD, betaR, nD, nR, kRS, tau, kappaC):
    """
    Assumes a square NxN grid of cells
    """
    # confirm that the reshape procedure worked properly
    assert len(x) % 3 == 0
    numcells = int(len(x)/3)
    numrows = np.sqrt(numcells)
    assert numrows % 1 == 0
    numrows = int(numrows)

    # Reshape the concentration array to 3D
    x3 = x.reshape((numrows,numrows,3))
    dxdt_3 = np.empty((numrows, numrows, 3))

    for i in range(numrows):
        for j in range(numrows):
            Ni = x3[i,j,0]
            Di = x3[i,j,1]
            Ri = x3[i,j,2]
            Djs = get_Djs_hex_periodic(x3, i, j)
            Njs = get_Njs_hex_periodic(x3, i, j)

            dxdt_3[i,j,0] = (betaN - Ni - Ni*Djs - Ni*Di/kappaC)/tau
            dxdt_3[i,j,1] = (betaD/(1+Ri**nD) - Di - Njs*Di - Ni*Di/kappaC)/tau
            dxdt_3[i,j,2] = betaR*(Ni*Djs)**nR / (kRS**nR + (Ni*Djs)**nR) - Ri

    # Reshape output array to 1D
    dxdt = dxdt_3.reshape(numrows*numrows*3,)

    return dxdt

# Parameters
betaN = 10
betaD = 100
betaR = 1e6
nD = 1
nR = 3
kRS = 3e5 ** (1/nR)
tau = 1
kappaC = 0.1

numrows = 12

# Define initial conditions
Ni0 = 10
Di0 = 1e-3
Ri0 = 0
Ni0_std = Ni0 / 10 / 2 # 98% of samples will be within 10% of Ni0
Di0_std = Di0 / 10 / 2 # 98% of samples will be within 10% of Di0

x0_3 = np.empty((numrows,numrows,3))

for i in range(numrows):
    for j in range(numrows):
        x0_3[i,j,0] = np.random.normal(Ni0, Ni0_std)
        x0_3[i,j,1] = np.random.normal(Di0, Di0_std)
        x0_3[i,j,2] = 0

x0 = x0_3.reshape(numrows*numrows*3,)

# Solve ODEs
tmax = 30
numpoints = 1000

t = np.linspace(0, tmax, numpoints)
args = (betaN, betaD, betaR, nD, nR, kRS, tau, kappaC)
x = scipy.integrate.odeint(LIMI_hex_periodic_rhs, x0, t, args=args)
x = x.transpose()

x4 = x.reshape((numrows,numrows,3,1000))
[7]:
# Set up plots
pT = bokeh.plotting.figure(
    frame_width=375, frame_height=200,
    x_axis_label="dimensionless time",
    y_axis_label="dimensionless\nconcentration", y_axis_type="log",
    x_range = [0, tmax], y_range=[3e-9,1e4],
    title="cis-inhibition, 12x12 periodic hexagonal grid"
)
pND = bokeh.plotting.figure(
    frame_width=250, frame_height=250,
    x_axis_label="dimensionless\nNotch concentration", x_axis_type="log",
    y_axis_label="dimensionless\nDelta concentration", y_axis_type="log",
    x_range=[1e-2, 2e1], y_range=[4e-4, 1e2]
)

# Slider to control time
time_slider = bokeh.models.Slider(title="time index", start=0, end=numpoints-1, step=1, value=350)

# Set up MultiLine Glyph
NDsource = ColumnDataSource(dict(
        ts = list(np.tile(t[:time_slider.value], (numrows*numrows,1))),
        Ns = list(np.reshape(x4[:,:,0,:time_slider.value], (numrows*numrows, time_slider.value))),
        Ds = list(np.reshape(x4[:,:,1,:time_slider.value], (numrows*numrows, time_slider.value))),
        Rs = list(np.reshape(x4[:,:,2,:time_slider.value], (numrows*numrows, time_slider.value))),
    )
)
Tsource = ColumnDataSource(dict(
        ts = list(np.tile(t[:], (numrows*numrows,1))),
        Ns = list(np.reshape(x4[:,:,0,:], (numrows*numrows, numpoints))),
        Ds = list(np.reshape(x4[:,:,1,:], (numrows*numrows, numpoints))),
        Rs = list(np.reshape(x4[:,:,2,:], (numrows*numrows, numpoints))),
    )
)
t_cds = ColumnDataSource(dict(curr_t = np.repeat(t[time_slider.value - 1], 2), y=np.array([1e-9,1e4])))

N_glyph = MultiLine(xs="ts", ys="Ns", line_color="blue", line_width=2, line_alpha=.2, syncable=False)
D_glyph = MultiLine(xs="ts", ys="Ds", line_color="red", line_width=2, line_alpha=.2, syncable=False)
R_glyph = MultiLine(xs="ts", ys="Rs", line_color="green", line_width=2, line_alpha=.2, syncable=False)
pT.add_glyph(Tsource, N_glyph)
pT.add_glyph(Tsource, D_glyph)
pT.add_glyph(Tsource, R_glyph)

ND_glyph = MultiLine(xs="Ns", ys="Ds", line_color="black", line_width=2, line_alpha=0.2)
pND.add_glyph(NDsource, ND_glyph)
T_glyph = Line(x="curr_t", y="y", line_color="black", line_width=2)
pT.add_glyph(t_cds, T_glyph)

# Display
if interactive_python_plots:

    # Set up callbacks
    def _callback(attr, old, new):
        t_ls = np.tile(t[:time_slider.value], (numrows*numrows,1))
        N_ls = np.reshape(x4[:,:,0,:time_slider.value], (numrows*numrows, time_slider.value))
        D_ls = np.reshape(x4[:,:,1,:time_slider.value], (numrows*numrows, time_slider.value))
        # R_ls = np.reshape(x4[:,:,2,:time_slider.value], (numrows*numrows, time_slider.value))
        NDsource.data = dict(
                            ts = list(t_ls),
                            Ns = list(N_ls),
                            Ds = list(D_ls),
                            # Rs = list(R_ls),
                        )
        t_cds.data = dict(dict(curr_t = np.repeat(t[time_slider.value -1], 2), y=np.array([1e-9,1e4])))


    time_slider.on_change("value_throttled", _callback)

    LIMI_hex_tcourse_layout = bokeh.layouts.row(
        bokeh.layouts.column(
            pT,
            time_slider,
        ),
        pND
    )

    # Build the app
    def LIMI_hex_tcourse_app(doc):
        doc.add_root(LIMI_hex_tcourse_layout)

    bokeh.io.show(LIMI_hex_tcourse_app, notebook_url=notebook_url)
else:

    time_slider.disabled=True

    LIMI_hex_tcourse_layout = bokeh.layouts.row(
    bokeh.layouts.column(
        pT,
        time_slider,
    ),
    bokeh.layouts.column(
        pND,
        bokeh.models.Div(
                            text="""
        <p>Sliders are inactive. To get active sliders, re-run notebook with
        <font style="font-family:monospace;">fully_interactive_plots = True</font>
        in the first code cell.</p>
                """,
                            width=250,
                        ),
        )
    )
    bokeh.io.show(LIMI_hex_tcourse_layout)

While in the model without cis-inhibition we saw that most cells had converged to their respective steady-state \((N_i,D_i)\) states by around 15 dimensionless time units, in this model most cells have converged to their \((N_i,D_i)\) states within about 7 dimensionless time units, essentially doubling the speed of the pattern formation! Interestingly, we also see from our plot in the \((N_i,D_i)\) plane that the shape of the trajectories the cells take toward their final states is also quite different compared to the model without cis-inhibition.

We can also plot out the spatial grid for the simulation, as before, to see if the cis-inhibition model was able to successfully generate a lateral inhibition pattern.

[8]:
# Widgets for controlling time
time_slider = bokeh.models.Slider(title="time index", start=0, end=numpoints-1, step=1, value=numpoints-1)

# extract Ri timecourse solutions
xR = x4[:,:,2,:]
Rmin = np.ma.masked_array(xR, mask=xR==0).min() # smallest nonzero value in xR
Rmax = xR.max()
color_mapper = LogColorMapper(palette="Viridis256", low=Rmin, high=Rmax)

xx, yy = np.meshgrid(np.arange(numrows), np.arange(numrows))
xx = xx.flatten()
yy = yy.flatten()

Rvals = np.empty(len(xx))
for i in range(len(Rvals)):
    Rvals[i] = xR[xx[i], yy[i], time_slider.value]
colors = matplotlib.cm.viridis(Rvals/Rmax)

phex = Plot(
    title='R(t) in each cell, cis-inhibition Model', width=450, height=300,
    min_border=0,
    toolbar_location=None
)

source = ColumnDataSource(dict(
        q=xx,
        r=yy,
        colors=colors,
    )
)

glyph = HexTile(q="q", r="r", size=1, fill_color="colors", line_color="white")
phex.add_glyph(source, glyph)

# text = Text(x = 12, y = 12, text="curr_t")
# phex.add_glyph(source, text)

color_bar = ColorBar(color_mapper=color_mapper, label_standoff=12)

phex.add_layout(color_bar, 'right')

# Display
if interactive_python_plots:

    # Set up callbacks
    def _callback(attr, old, new):
        Rvals = np.empty(len(xx))
        for i in range(len(Rvals)):
            Rvals[i] = xR[xx[i], yy[i], time_slider.value]
        colors = matplotlib.cm.viridis(Rvals/Rmax)
        source.data = dict(q=xx, r=yy, colors=colors)

    time_slider.on_change("value", _callback)

    # Build layout
    hex_layout = bokeh.layouts.column(
        phex,
        bokeh.layouts.Spacer(height=10),
        time_slider
    )

    # Build the app
    def hex_app(doc):
        doc.add_root(hex_layout)

    bokeh.io.show(hex_app, notebook_url=notebook_url)
else:
    time_slider.disabled=True

    hex_layout = bokeh.layouts.column(
        phex,
        bokeh.layouts.Spacer(height=10),
        time_slider,
        bokeh.models.Div(
                            text="""
        <p>Sliders are inactive. To get active sliders, re-run notebook with
        <font style="font-family:monospace;">fully_interactive_plots = True</font>
        in the first code cell.</p>
                """,
                            width=250,
                        ),
    )


    bokeh.io.show(hex_layout)

We can see from this visualization that although the timecourse plot makes it look like \(R_i\) has dynamics that last for a long time, in reality these dynamics are not so meaningful from the point of view of the pattern— the formation of the pattern occurs quite quickly compared to the lateral inhibition model without cis-inhibition, despite the fact that all of the shared parameters were identical in the two conditions.

Notice, however, that the pattern here is less globally uniform than the one that we saw in our model without cis-inhibition. Why might this be? One possible explanation relies on the fact that when cells enter a low-Delta or low-Notch state, they become functionally unable to send or receive signal, which “locks in” their local environment because the cells can no longer communicate with their neighbors. It is possible that in the cis-inhibition model, the initial sites where the pattern spontaneously and independently forms mature so quickly that they have “locked in” before that pattern source’s boundaries have grown to encounter another pattern source’s boundary. In contrast, in the model without cis-inhibtion, the timescales of pattern expansion and pattern maturation might be closer to each other, so that all of the initial pattern sources have encountered each other and are able to synchronize globally before cells fully “lock in” to their respective states.

The explanation given above is just one possible hypothesis to explain the different behaviors of the two models, which differ only in the presence of the single additional parameter, \(\kappa_C\). How might you analyze these two models in order to refute or support this hypothesis? What other potential explanations can you come up with for this discrepancy?

cis-inhibition removes the cooperativity requirement for lateral inhibition

Another natural question to ask when comparing the two models is whether the presence of cis-inhibition allows the pattern to form more robustly with respect to the values of the system’s parameters. However, in order to perform a parameter search, we need to have an easily-calculatable low-dimensional representation of the pattern formation. When analyzing molecular oscillators like the Repressilator (Ch 9, 10), we did this by first determining the number of fixed points, and if there was only one, using linear stability analysis to determine if it is unstable. In this way we were able to rule out oscillations, although we were never able to guarnatee the presence of oscillations in this way.

Sprinzak et al. took a similar approach for the lateral inhibition models, where they performed linear stability analysis on the homogeneous steady state where all cells have the same concentrations of each species, in other words \(N_i = N^*\), \(D_i = D^*\), and \(R_i = R^*\) for all cells \(i\). This is the state that represents the initial, unpatterned state of the cellular population, and it is indeed a steady state, which is assured by the following construction: 1. Couple the concentrations of each species between cells so that all cells have the same concentration value for that species. In other words, set \(N_i = N\), \(D_i = D\), and \(R_i = R\) for all \(i\) and thereby reduce the system to a 3-equation ODE system. 2. This system has at least one steady state. Pick one of them and call it \((N^*, D^*, R^*)\). 3. Since the point \((N^*, D^*, R^*)\) is a steady state of the homogenous system, it satisfies \(\mathrm{d}N/\mathrm{d}t = \mathrm{d}D/\mathrm{d}t = \mathrm{d}R/\mathrm{d}t = 0\). 4. Therefore, the point \((N^*, D^*, R^*)\) also satisfies \(\mathrm{d}N_i/\mathrm{d}t = \mathrm{d}D_i/\mathrm{d}t = \mathrm{d}R_i/\mathrm{d}t = 0\) for any \(i\). 5. Therefore the point \((N^*, D^*, R^*)\) is a homogeneous steady state of the system.

This idea is actually a general principle for spatial patterning dynamics: in order for spontaneous pattern formation to occur, the system’s homogenous steady state must be unstable. If this steady state were stable, then small perturbations from the homogenous steady state would not cause the unpatterned state to change into a more structured form. However, it is important to remember that this condition is not a sufficient condition for pattern formation— it’s possible that the cells would leave their homogenous steady state but converge to some nonhomogenous but random, unstructured assortment of cell states that we would be hard-pressed to call a pattern. Therefore, without additional information, linear stability analysis of this type can only be used to rule out spontaneous pattern formation, and cannot guarantee its occurence, for a particular parameterization of the system.

Unlike the oscillator models that we analyzed in chapters 9 and 10, however, the Jacobian matrix for our lateral inhibition models are extremely high-dimensional. For our \(12\times 12\) grid of cells, we would need to calculate the eigenvalues of a \(432\times 432\) matrix! In order to make this task feasible, Sprinzak et al. used a clever derivation by Othmer and Scriven (1971, Journal of Theoretical Biology), who were able to use the repetitive structure within the Jacobian matrix to prove that its eigenvalues are related to the eigenvalues of a much smaller matrix in such a way that the required stability information for the homogenous steady state can be fully determined by the smaller \(3\times 3\) matrix. While the mathematical techniques involved in this derivation are beyond the scope of this course, interested readers are invited to read section S1.4 of the supplemental information of Sprinzak et al. (2011, PLoS Computational Biology) for an outline of the approach.

After performing the linear stability analysis, Sprinzak et al. found that the model without cis-inhibition (the LI model) was unable to spontaneously form patterns when the activation of the reporter by Notch (\(n_R\) in our model) was not cooperative. In contrast, the model with cis-inhibition (the LIMI model) had a region of parameter space where spontaneous pattern formation might be possible.

Linear Stability Analaysis Heatmaps

As we stated above, the presence of a positive eigenvalue in this region of parameter space is not a guarantee of pattern formation. However, once we have identified this potential patterning region, we can simulate our model with the appropriate parameter values and determine whether it is able to perform pattern formation. We do so below:

[9]:
# Parameters
betaN = 30
betaD = 1000
betaR = 1e6
nD = 1
nR = 1
kRS = 3e5 ** (1/nR)
tau = 1
kappaC = 0.1

numrows = 8

# Define initial conditions
Ni0 = 10
Di0 = 1e-3
Ri0 = 0
Ni0_std = Ni0 / 10 / 2 # 98% of samples will be within 10% of Ni0
Di0_std = Di0 / 10 / 2 # 98% of samples will be within 10% of Di0

x0_3 = np.empty((numrows,numrows,3))

# Create list of seed values for reproducibility
np.random.seed(1234)
# np.random.seed(12345) # this one is interesting
seed_vals = np.random.randint(low=1, high=999999999, size=(numrows, numrows, 2))
for i in range(numrows):
    for j in range(numrows):
        np.random.seed(seed_vals[i,j,0])
        x0_3[i,j,0] = np.random.normal(Ni0, Ni0_std)
        np.random.seed(seed_vals[i,j,1])
        x0_3[i,j,1] = np.random.normal(Di0, Di0_std)
        x0_3[i,j,2] = 0

x0 = x0_3.reshape(numrows*numrows*3,)

# Solve ODEs
tmax = 150
numpoints = 4000

t = np.linspace(0, tmax, numpoints)
args = (betaN, betaD, betaR, nD, nR, kRS, tau, kappaC)
x = scipy.integrate.odeint(LIMI_hex_periodic_rhs, x0, t, args=args)
x = x.transpose()

x4 = x.reshape((numrows,numrows,3,numpoints))
[10]:
# Set up plots
pT = bokeh.plotting.figure(
    frame_width=375, frame_height=200,
    x_axis_label="dimensionless time",
    y_axis_label="dimensionless\nconcentration", y_axis_type="log",
    x_range = [0, tmax], y_range=[3e-9,1e4],
    title="non-cooperative cis-inhibition, 8x8 periodic hexagonal grid"
)
pND = bokeh.plotting.figure(
    frame_width=250, frame_height=250,
    x_axis_label="dimensionless\nNotch concentration", x_axis_type="log",
    y_axis_label="dimensionless\nDelta concentration", y_axis_type="log",
    x_range=[1e-3, 2e1], y_range=[4e-4, 1e3]
)

# Slider to control time
time_slider = bokeh.models.Slider(title="time index", start=0, end=numpoints-1, step=1, value=numpoints-100)

# Set up MultiLine Glyph
NDsource = ColumnDataSource(dict(
        ts = list(np.tile(t[:time_slider.value], (numrows*numrows,1))),
        Ns = list(np.reshape(x4[:,:,0,:time_slider.value], (numrows*numrows, time_slider.value))),
        Ds = list(np.reshape(x4[:,:,1,:time_slider.value], (numrows*numrows, time_slider.value))),
        Rs = list(np.reshape(x4[:,:,2,:time_slider.value], (numrows*numrows, time_slider.value))),
    )
)
Tsource = ColumnDataSource(dict(
        ts = list(np.tile(t[:], (numrows*numrows,1))),
        Ns = list(np.reshape(x4[:,:,0,:], (numrows*numrows, numpoints))),
        Ds = list(np.reshape(x4[:,:,1,:], (numrows*numrows, numpoints))),
        Rs = list(np.reshape(x4[:,:,2,:], (numrows*numrows, numpoints))),
    )
)
t_cds = ColumnDataSource(dict(curr_t = np.repeat(t[time_slider.value - 1], 2), y=np.array([1e-9,1e4])))

N_glyph = MultiLine(xs="ts", ys="Ns", line_color="blue", line_width=2, line_alpha=1, syncable=False)
D_glyph = MultiLine(xs="ts", ys="Ds", line_color="red", line_width=2, line_alpha=1, syncable=False)
R_glyph = MultiLine(xs="ts", ys="Rs", line_color="green", line_width=2, line_alpha=1, syncable=False)
pT.add_glyph(Tsource, N_glyph)
pT.add_glyph(Tsource, D_glyph)
pT.add_glyph(Tsource, R_glyph)

ND_glyph = MultiLine(xs="Ns", ys="Ds", line_color="black", line_width=2, line_alpha=0.2)
pND.add_glyph(NDsource, ND_glyph)
T_glyph = Line(x="curr_t", y="y", line_color="black", line_width=2)
pT.add_glyph(t_cds, T_glyph)

# Display
if interactive_python_plots:

    # Set up callbacks
    def _callback(attr, old, new):
        t_ls = np.tile(t[:time_slider.value], (numrows*numrows,1))
        N_ls = np.reshape(x4[:,:,0,:time_slider.value], (numrows*numrows, time_slider.value))
        D_ls = np.reshape(x4[:,:,1,:time_slider.value], (numrows*numrows, time_slider.value))
        # R_ls = np.reshape(x4[:,:,2,:time_slider.value], (numrows*numrows, time_slider.value))
        NDsource.data = dict(
                            ts = list(t_ls),
                            Ns = list(N_ls),
                            Ds = list(D_ls),
                            # Rs = list(R_ls),
                        )
        t_cds.data = dict(dict(curr_t = np.repeat(t[time_slider.value -1], 2), y=np.array([1e-9,1e4])))


    time_slider.on_change("value_throttled", _callback)

    LIMI_hex_tcourse_layout = bokeh.layouts.row(
        bokeh.layouts.column(
            pT,
            time_slider,
        ),
        pND
    )

    # Build the app
    def LIMI_hex_tcourse_app(doc):
        doc.add_root(LIMI_hex_tcourse_layout)

    bokeh.io.show(LIMI_hex_tcourse_app, notebook_url=notebook_url)
else:

    time_slider.disabled=True

    LIMI_hex_tcourse_layout = bokeh.layouts.row(
    bokeh.layouts.column(
        pT,
        time_slider,
    ),
    bokeh.layouts.column(
        pND,
        bokeh.models.Div(
                            text="""
        <p>Sliders are inactive. To get active sliders, re-run notebook with
        <font style="font-family:monospace;">fully_interactive_plots = True</font>
        in the first code cell.</p>
                """,
                            width=250,
                        ),
        )
    )
    bokeh.io.show(LIMI_hex_tcourse_layout)

We see a different type of timecourse trajectory from what we have seen before in our models— first of all, we notice that the dynamics are much slower than we have seen before. But also we can see, particularly in the \((N_i, D_i)\) plot, that the system takes a less direct path to its final steady state, sometimes doubling back on itself before getting there. We also see that some outlier cells take a very long time to eventually converge to their steady state. But are the cells really forming a lateral inhibition pattern? We must plot out the spatial grid to know for sure.

[11]:
# Widgets for controlling time
time_slider = bokeh.models.Slider(title="time index", start=0, end=numpoints-1, step=1, value=numpoints-1)

# extract Ri timecourse solutions
xR = x4[:,:,2,:]
Rmin = np.ma.masked_array(xR, mask=xR==0).min() # smallest nonzero value in xR
Rmax = xR.max()
color_mapper = LogColorMapper(palette="Viridis256", low=Rmin, high=Rmax)

xx, yy = np.meshgrid(np.arange(numrows), np.arange(numrows))
xx = xx.flatten()
yy = yy.flatten()

Rvals = np.empty(len(xx))
for i in range(len(Rvals)):
    Rvals[i] = xR[xx[i], yy[i], time_slider.value]
colors = matplotlib.cm.viridis(Rvals/Rmax)

phex = Plot(
    title='R(t), Non-cooperative cis-inhibition Model', width=450, height=300,
    min_border=0,
    toolbar_location=None
)

source = ColumnDataSource(dict(
        q=xx,
        r=yy,
        colors=colors,
    )
)

glyph = HexTile(q="q", r="r", size=1, fill_color="colors", line_color="white")
phex.add_glyph(source, glyph)

color_bar = ColorBar(color_mapper=color_mapper, label_standoff=12)

phex.add_layout(color_bar, 'right')

# Display
if interactive_python_plots:
    # Set up callbacks
    def _callback(attr, old, new):
        Rvals = np.empty(len(xx))
        for i in range(len(Rvals)):
            Rvals[i] = xR[xx[i], yy[i], time_slider.value]
        colors = matplotlib.cm.viridis(Rvals/Rmax)
        source.data = dict(q=xx, r=yy, colors=colors)

    time_slider.on_change("value", _callback)

    # Build layout
    hex_layout = bokeh.layouts.column(
        phex,
        bokeh.layouts.Spacer(height=10),
        time_slider
    )

    # Build the app
    def hex_app(doc):
        doc.add_root(hex_layout)

    bokeh.io.show(hex_app, notebook_url=notebook_url)
else:
    time_slider.disabled=True

    hex_layout = bokeh.layouts.column(
        phex,
        bokeh.layouts.Spacer(height=10),
        time_slider,
        bokeh.models.Div(
                            text="""
        <p>Sliders are inactive. To get active sliders, re-run notebook with
        <font style="font-family:monospace;">fully_interactive_plots = True</font>
        in the first code cell.</p>
                """,
                            width=250,
                        ),
    )


    bokeh.io.show(hex_layout)

Indeed, we see that the system has indeed formed a lateral inhibition pattern! We can now confidently say that from our analysis that the addition of cis-inhibition to our model of Notch signaling allows it to spontaneously generate lateral inhibition patterns in the absence of cooperative regulation.

Computing environment

[12]:
%load_ext watermark
%watermark -v -p numpy,scipy,bokeh,matplotlib,colorcet,biocircuits,jupyterlab
Python implementation: CPython
Python version       : 3.9.12
IPython version      : 8.3.0

numpy      : 1.21.5
scipy      : 1.7.3
bokeh      : 2.4.2
matplotlib : 3.5.1
colorcet   : 3.0.0
biocircuits: 0.1.8
jupyterlab : 3.3.2