Roy Friedman, Noa Moriel, Matthew Ricci, Guy Pelc, Yair Weiss, Mor Nitzan
Abstract: Characterizing the long term behavior of dynamical systems given limited measurements is a common challenge throughout the physical and biological sciences. This is a challenging task due to the sparsity and noise inherent to empirical observations, as well as the variability of possible longterm dynamics. We address this by introducing smooth prototype equivalences (SPE), a framework for matching sparse observations to prototypical behaviors using invertible neural networks which model smooth phase space deformations. SPE can localize the invariant sets describing long-term behavior of the observed dynamics through the learned mapping from prototype space to data space. Furthermore, SPE can classify dynamical regimes by comparing the data residual of the deformed measurements to prototype dynamics. Our method outperforms existing techniques in the classification of oscillatory systems and can efficiently identify invariant structures like limit cycles and fixed points in an equation-free manner, even when only a small, noisy subset of the phase space is observed. SPE further reveals driving genes in synthetic oscillators such as the repressilator regulatory circuit, and traces cyclic biological processes like the cell cycle trajectory directly from experimental high-dimensional single-cell gene expression data.
This package requires Python 3.9 or later. Clone the repository and install with pip:
git clone https://github.com/nitzanlab/prototype-equivalences.git
cd prototype-equivalences
pip install .The main dependencies (torch, numpy, scipy, matplotlib, tqdm, POT, torchdiffeq, pysindy) are installed automatically.
SPE characterizes observations from a vector field, i.e. the set $D=\{(x_i,\dot{x}i)\}{i=1}^N$ comprised of positions (
Once
Every use of the package follows the same three lines:
from spe.SPE import SPEModel, fit_prototype
model = SPEModel(dim=x.shape[-1], prototype=proto, n_layers=3, K=4)
model, loss, score = fit_prototype(model, x, xdot, its=1000, lr=1e-3)
invariant = model.get_invariant(N=100) # the prototype's invariant set, in data coordinateswhere x and xdot are torch tensors of shape [N, dim], and proto is one of the prototypes described below. fit_prototype returns the fitted model, the final loss and the score, which is the equivalence error (used to compare how well different prototypes explain the same data).
The package lives in src/spe/ and has four submodules:
| module | contents |
|---|---|
spe.SPE |
SPEModel (diffeomorphism + prototype), fit_prototype (the fitting procedure), and fit_all_prototypes (fit a list of prototypes to the same data, for classification) |
spe.dynamics |
prototypes.py — the Prototype base class and all implemented prototypes; systems.py — simulated systems (Van der Pol, repressilator, Selkov, …); utils.py — trajectory simulation and distance/comparison metrics |
spe.models |
NFDiffeo.py — the Diffeo normalizing flow used as baselines.py and SINDY_AE.py — neural ODE, SINDy and SINDy-autoencoder baselines |
spe.pp |
preprocessing for single-cell data: RNA_velocity / prepare_RNA_velocity_embedding and ot_velocities (optimal-transport velocities from time-resolved snapshots) |
The SPEModel wraps the two pieces: model.H is the diffeomorphism (with forward, reverse, jvp_forward and jvp_reverse) and model.proto is the prototype. Any keyword argument not recognized by SPEModel is passed to Diffeo, the most useful being n_layers (number of coupling layers), K (hidden units), and n_householder (number of Householder rotations).
Runnable examples are in notebooks/; the three below are the ones worth reading first.
The simplest possible use of SPE is to recover a limit cycle from sampled vectors. The prototype here is the simple oscillator (SO), defined in polar coordinates as:
$\dot{r}=r(a-r^2)$ $\dot{\theta}=\omega$
so that dict of its parameters:
import torch
from spe.dynamics.systems import VanDerPol
from spe.SPE import SPEModel, fit_prototype
# sample positions and velocities from a Van der Pol system with a limit cycle
system = VanDerPol(**VanDerPol.random_cycle_params())
x = system.rand_on_traj(system.random_x(200), T=3)
xdot = system(x)
# fit a simple-oscillator prototype to the observations
model = SPEModel(dim=2, prototype={'a': .25, 'omega': -.5}, n_layers=3, K=4)
model, loss, score = fit_prototype(model, x, xdot, its=1500, lr=5e-3,
det_reg=1e-3, weight_decay=1e-3, verbose=True)
# the estimated limit cycle, in the coordinates of the data
cycle = model.get_invariant(N=100).detach().numpy()See notebooks/demo.ipynb for a more in depth example, which shows the fit to both the 2D Van der Pol system and a 6D repressilator and then compares the recovered cycle against the ground-truth trajectory gene by gene.
To find sinks in the dynamical system, use the Attractor prototype, which models the dynamics as a pure decay towards the origin,
import torch
from spe.SPE import SPEModel, fit_prototype
from spe.dynamics.prototypes import Attractor
x = torch.from_numpy(positions).float() # positions, [N, dim]
xdot = torch.from_numpy(velocities).float() # velocities, [N, dim]
model = SPEModel(dim=x.shape[-1], prototype=Attractor(), n_layers=2, K=4)
model, _, _ = fit_prototype(model, x, xdot, its=1000, lr=1e-3, weight_decay=1e-3,
det_reg=1e-3, freeze_frac=50)
# the fixed point of the prototype (the origin) pulled back into data coordinates
with torch.no_grad():
attractor = model.H.reverse(torch.zeros(1, x.shape[-1]))Note that freeze_frac can be given either as a fraction of the iterations or, as here, as a number of iterations; it keeps the nonlinear part of
Any prototype can be swapped in for the two above; the fitting code is identical, only the invariant set and what it means change. The implemented prototypes, all in spe/dynamics/prototypes.py, are:
| prototype | behavior |
|---|---|
SOPrototype |
simple oscillator: limit cycle ( |
Attractor |
a single stable fixed point |
HeteroclinicFlip |
one source whose unstable manifold connects to two sinks — a bifurcation |
DualCusp |
cusp-type bistability |
As an example, notebooks/mouse_endoderm.ipynb fits HeteroclinicFlip to the ICM
import torch
from spe.pp import ot_velocities
from spe.SPE import SPEModel, fit_prototype
from spe.dynamics.prototypes import HeteroclinicFlip
velocities = ot_velocities(adata.obsm['X_pca'], timepoints, extend_last=False, k=30)
x = torch.from_numpy(adata.obsm['X_pca'].copy()).float()
xdot = torch.from_numpy(velocities).float()
proto = HeteroclinicFlip()
model = SPEModel(dim=x.shape[-1], prototype=proto, n_layers=2, K=4, n_householder=6)
model, _, _ = fit_prototype(model, x, xdot, its=1000, lr=5e-4, verbose=True,
det_reg=1e-3, weight_decay=1e-3, poten_reg=1e-3, freeze_frac=300)
# the invariant set: one source (the progenitor) and two sinks (the two fates)
inv = model.proto.get_invariant(N=3, dim=x.shape[-1])
# embed the data in prototype ("SPE") coordinates, for plotting
lat, latdot, _ = model.H.jvp_forward(x, xdot)
# trajectories of the prototype, started on either side of the source, describe
# the two differentiation branches; H.reverse maps them back to data coordinates
inits = torch.zeros(2, x.shape[-1])
inits[:, 1], inits[0, 0], inits[1, 0] = 1, -.1, .1
branches = model.proto.trajectories(inits, T=2.)
pca_branches = model.H.reverse(branches.reshape(-1, x.shape[-1]))Since H is invertible, points sampled anywhere in prototype space can be mapped back through poten_reg is available for gradient-like prototypes such as this one, and penalizes the fitted field by the prototype's potential.
A prototype is an nn.Module subclassing Prototype, and needs at minimum a forward that returns get_invariant (points on the invariant set) and project_onto_invariant (nearest point of the invariant set) makes SPEModel.get_invariant work, and potential enables poten_reg. By convention the dynamics of interest live in the first two coordinates and the remaining dim-2 decay exponentially, which is what lets the same prototype be used at any dimension.
Passing optimize=True to the built-in prototypes turns their parameters into nn.Parameters, so they are fitted jointly with
Because the score returned by fit_prototype measures how well a prototype explains the data, fitting several prototypes and comparing scores classifies the observed dynamics. fit_all_prototypes handles this natively:
from spe.SPE import fit_all_prototypes
results = fit_all_prototypes(
x, xdot,
prototypes=[{'a': -.25, 'omega': .5}, {'a': .25, 'omega': .5}], # node vs. cycle
diffeo_args=dict(n_layers=3, K=4),
fitting_args=dict(its=1000, lr=5e-3, det_reg=1e-3),
)
best = results['prototypes'][int(np.argmin(results['scores']))]It returns a dictionary with the prototypes, their losses and scores, and the fitted models under Hs.
Please be in touch if you have any questions! For contact, you can email Roy Friedman at roy.friedman@mail.huji.ac.il .
