Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

How to use the FIM-ODE Model

This tutorial shows how to use FIM-ODE — a Foundation Inference Model for Ordinary Differential Equations — to recover vector fields from a handful of noisy, subsampled trajectories.

Background

Many natural phenomena are described by autonomous ODEs of the form

x˙(t)=f(x(t)),xRD,\dot{x}(t) = f(x(t)), \qquad x \in \mathbb{R}^D,

where f:RDRDf : \mathbb{R}^D \to \mathbb{R}^D is an unknown vector field. Inferring ff from data is difficult: observations are noisy, sparsely sampled, and each experiment yields only a few short trajectories.

FIM-ODE addresses this by amortising inference: a transformer trained across millions of synthetic systems maps a small context of (ti,xi)(t_i, x_i) pairs directly to a continuous vector field f^\hat{f}, without any gradient-based fitting at test time.

What this tutorial covers

We demonstrate FIM-ODE on three 2-D systems from the ODEBench benchmark:

IDSystemEquations
26Lotka–Volterra Competitionx˙0=x0(c0x0c1x1), x˙1=x1(c2x0x1)\dot x_0 = x_0(c_0 - x_0 - c_1 x_1),\ \dot x_1 = x_1(c_2 - x_0 - x_1)
42CDIMA chemical oscillatorx˙0=c0x0c1x0x11+x02, x˙1=c2x0 ⁣(1x11+x02)\dot x_0 = c_0 - x_0 - \frac{c_1 x_0 x_1}{1+x_0^2},\ \dot x_1 = c_2 x_0\!\left(1 - \frac{x_1}{1+x_0^2}\right)
28Nonlinear pendulumx˙0=x1, x˙1=c0sin(x0)\dot x_0 = x_1,\ \dot x_1 = -c_0 \sin(x_0)

For each system we use a single noisy trajectory as context for FIM-ODE. The tutorial is structured as follows:

  1. Load one noisy, subsampled trajectory per system from ODEBench.

  2. Demonstrate vector field inference on CDIMA: integrate from two initial conditions and compare against clean ground-truth trajectories.

  3. Find and classify fixed points — both ground truth (via SymPy) and FIM-ODE (numerically).

  4. Visualise phase portraits with streamlines, integrated trajectories, and fixed point annotations.

  5. Apply FIM-ODE to real motion-capture data (CMU MoCap, Subject 09) and report zero-shot MSE in 50D joint-angle space.

import warnings
warnings.filterwarnings("ignore")
from transformers.utils import logging
logging.disable_progress_bar()
import numpy as np
import torch
import matplotlib.pyplot as plt
import sympy as sp

from ode_tutorial_helper import (
    load_odebench_system,
    make_fim_vf_fn,
    find_fixed_points,
    numerical_jacobian,
    stability_analysis,
    integrate_from_context,
)

device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using device: {device}')
Using device: cpu

1. Loading FIM-ODE

The pre-trained weights are hosted on the Hugging Face Hub at FIM4Science/fim-ode. The first call downloads and caches the model; subsequent calls load from the local cache.

from fim.models.ode import load_fim_ode_hf

model = load_fim_ode_hf(device=device)
model.eval()
print(f'Parameters: {sum(p.numel() for p in model.parameters()):,}')
Parameters: 12,968,068

2. Loading ODEBench trajectories

ODEBench provides reference trajectories for each system integrated from multiple initial conditions. We download the benchmark JSON from the Hugging Face Hub and add 3 % multiplicative Gaussian noise with 50 % subsampling to simulate realistic observations. We use one noisy trajectory as context for FIM-ODE throughout.

from huggingface_hub import hf_hub_download

# Download the ODEBench system definitions (3.5 MB JSON, cached after first run)
odebench_json = hf_hub_download(
    repo_id='FIM4Science/odebench',
    filename='strogatz_extended.json',
    repo_type='dataset',
)
print(f'ODEBench JSON: {odebench_json}')
ODEBench JSON: /Users/patrickseifner/.cache/huggingface/hub/datasets--FIM4Science--odebench/snapshots/91909865498f5d4bd69ea575527fee50ad329ef4/strogatz_extended.json
SEED         = 0
SYSTEM_IDS   = [26, 42, 28]
SYSTEM_NAMES = {26: 'LV Competition', 42: 'CDIMA', 28: 'Pendulum'}
N_CONTEXT    = 1   # one noisy trajectory used as context throughout

systems = {}
for sid in SYSTEM_IDS:
    times, trajs, meta = load_odebench_system(
        sid,
        json_path=odebench_json,
        noise_sigma=0.03,
        subsample_fraction=0.5,
        rng=np.random.default_rng(SEED),
    )
    systems[sid] = dict(times=times[:N_CONTEXT], trajs=trajs[:N_CONTEXT], meta=meta)
    print(f'System {sid:>3d} ({SYSTEM_NAMES[sid]:<20s}):  '
          f'context = {N_CONTEXT} trajectory × {trajs.shape[1]} steps × {trajs.shape[2]}D')

# Also load clean (noise-free, full-resolution) trajectories for plotting and IC integration
clean_systems = {}
for sid in SYSTEM_IDS:
    ct, cy, _ = load_odebench_system(
        sid, json_path=odebench_json, noise_sigma=0.0, subsample_fraction=1.0,
        rng=np.random.default_rng(SEED),
    )
    clean_systems[sid] = dict(times=ct, trajs=cy)
System  26 (LV Competition      ):  context = 1 trajectory × 256 steps × 2D
System  42 (CDIMA               ):  context = 1 trajectory × 256 steps × 2D
System  28 (Pendulum            ):  context = 1 trajectory × 256 steps × 2D

3. Vector field inference — a first example

Before analysing fixed points, we demonstrate what FIM-ODE actually produces: a continuous vector field inferred from a single noisy trajectory. We use the CDIMA chemical oscillator (system 42) as our example.

Protocol:

  • Context: noisy trajectory 1 (3 % multiplicative noise, 50 % subsampled).

  • Prediction: integrate the inferred vector field from two clean initial conditions.

  • Evaluation: compare against the noiseless ground-truth trajectories.

Each panel below shows one initial condition. Gray × markers are the noisy context observations; the black dashed curve is the clean ground truth; the colored solid curve is the FIM-ODE prediction integrated with RK45.

# CDIMA (system 42): integrate from two clean initial conditions
_sid   = 42
_ctx   = systems[_sid]['trajs']         # (1, T_ctx, 2) — noisy context
_ctx_ts= systems[_sid]['times'][0,:,0]  # (T_ctx,)
_clean = clean_systems[_sid]            # noise-free trajectories

TRAJ_COLORS = ['#D55E00', '#56B4E9']    
_n_ic = min(2, _clean['trajs'].shape[0])

_preds_cdima = []
for _k in range(_n_ic):
    _y0   = _clean['trajs'][_k, 0]
    _tev  = _clean['times'][_k, :, 0]
    _pred = integrate_from_context(model, _ctx, _ctx_ts, _y0, _tev, device)
    _preds_cdima.append(_pred)

fig, axes = plt.subplots(1, _n_ic, figsize=(6 * _n_ic, 5), constrained_layout=True)
fig.suptitle('CDIMA — FIM-ODE conditioned on one noisy trajectory', fontsize=13)
if _n_ic == 1:
    axes = [axes]

for _k, ax in enumerate(axes):
    _gt   = _clean['trajs'][_k]      # (T_clean, 2) — clean GT
    _pred = _preds_cdima[_k]         # (T_clean, 2) — FIM-ODE
    _noisy = _ctx[0]                 # (T_ctx, 2)   — noisy context points
    _c = TRAJ_COLORS[_k]

    ax.scatter(_noisy[:, 0], _noisy[:, 1],
               s=30, c='gray', marker='x', linewidths=1.5, alpha=0.6,
               zorder=2, label='noisy context (traj 1)')
    ax.plot(_gt[:, 0], _gt[:, 1], 'k--', lw=2, alpha=0.8,
            zorder=3, label=f'ground truth (IC {_k+1})')
    ax.plot(_pred[:, 0], _pred[:, 1], color=_c, lw=2.5, alpha=0.95,
            zorder=4, solid_capstyle='round', label=f'FIM-ODE (IC {_k+1})')
    ax.scatter(_pred[0, 0], _pred[0, 1], s=200, marker='s',
               facecolor=_c, edgecolors='black', linewidths=2, zorder=5)
    ax.text(_pred[0, 0], _pred[0, 1], str(_k), fontsize=10,
            ha='center', va='center', color='white', fontweight='bold', zorder=6)

    ax.set_title(f'Initial condition {_k + 1}', fontsize=11)
    ax.set_xlabel('$x_0$', fontsize=10)
    ax.set_ylabel('$x_1$', fontsize=10)
    ax.legend(fontsize=8, loc='best')
    ax.set_aspect('equal', adjustable='datalim')

plt.show()
<Figure size 1200x500 with 2 Axes>

4. Ground-truth fixed points

A fixed point xx^* satisfies f(x)=0f(x^*) = 0. Its stability is determined by the eigenvalues of the Jacobian J=Df(x)J = Df(x^*):

Eigenvalue patternClassification
All Re(λi)<0\text{Re}(\lambda_i) < 0stable node / spiral
All Re(λi)>0\text{Re}(\lambda_i) > 0unstable node / spiral
Mixed signssaddle
Pure imaginarycentre / marginal

We use SymPy to solve for fixed points and evaluate the Jacobian symbolically.

def sympy_fixed_points(eq_str, const_subs, label=''):
    """Symbolically find and classify fixed points of a 2-D ODE.

    Parameters
    ----------
    eq_str     : pipe-separated equation string, e.g. ``'x_1 | -c_0*sin(x_0)'``
    const_subs : list of constant values [c_0, c_1, ...]
    label      : printed header
    """
    eq_str = eq_str.replace('^', '**')
    parts  = [p.strip() for p in eq_str.split('|')]
    D      = len(parts)
    xs     = [sp.Symbol(f'x_{i}') for i in range(D)]
    cs     = {sp.Symbol(f'c_{i}'): v for i, v in enumerate(const_subs)}

    exprs  = [sp.sympify(p).subs(cs) for p in parts]
    J_sym  = sp.Matrix([[sp.diff(e, x) for x in xs] for e in exprs])

    try:
        fps_sym = sp.solve(exprs, xs, dict=True)
    except Exception:
        fps_sym = []

    results = []
    print(f'\n── {label} ──')
    for fp_dict in fps_sym:
        fp_num = np.array([complex(fp_dict.get(x, 0)) for x in xs])
        if np.any(np.abs(fp_num.imag) > 1e-8):
            continue
        fp_num = fp_num.real
        J_num  = np.array(J_sym.subs(fp_dict).evalf().tolist(), dtype=float)
        s      = stability_analysis(J_num)
        results.append(dict(fp=fp_num, J=J_num, **s))
        print(f'  x*  = {np.round(fp_num, 4)}   stability: {s["label"]}')
        print(f'  λ   = {np.round(s["eigenvalues"], 4)}')
    if not results:
        print('  No real fixed points found symbolically.')
    return results


gt_results = {}
for sid in SYSTEM_IDS:
    meta = systems[sid]['meta']
    gt_results[sid] = sympy_fixed_points(
        meta['eq'], meta['consts'],
        label=f'Ground truth — {SYSTEM_NAMES[sid]} (ID {sid})'
    )

── Ground truth — LV Competition (ID 26) ──
  x*  = [0. 0.]   stability: unstable node
  λ   = [3.+0.j 2.+0.j]
  x*  = [0. 2.]   stability: stable node
  λ   = [-2.+0.j -1.+0.j]
  x*  = [1. 1.]   stability: saddle
  λ   = [ 0.4142+0.j -2.4142+0.j]
  x*  = [3. 0.]   stability: stable node
  λ   = [-3.+0.j -1.+0.j]

── Ground truth — CDIMA (ID 42) ──
  x*  = [1.78   4.1684]   stability: unstable spiral
  λ   = [0.2415+1.712j 0.2415-1.712j]

── Ground truth — Pendulum (ID 28) ──
  x*  = [0. 0.]   stability: centre / marginal
  λ   = [0.+0.9487j 0.-0.9487j]
  x*  = [3.1416 0.    ]   stability: saddle
  λ   = [ 0.9487+0.j -0.9487+0.j]

5. FIM-ODE: encoding observations and finding fixed points

FIM-ODE encodes the noisy context trajectories once (a single forward pass through the transformer) and returns a neural vector field f^\hat f. We then search for its fixed points using a multi-start L-BFGS-B minimisation of f^(x)2\|\hat{f}(x)\|^2.

fim_results = {}
for sid in SYSTEM_IDS:
    s   = systems[sid]
    vf  = make_fim_vf_fn(model, s['trajs'], s['times'], device=device)
    fps = find_fixed_points(vf, D=2, n_starts=400, x_range=(-6, 6),
                             rng=np.random.default_rng(SEED), top_k=4)
    fim_results[sid] = dict(vf=vf, fps=fps)

    print(f'\n── FIM-ODE — {SYSTEM_NAMES[sid]} (ID {sid}) ──')
    if not fps:
        print('  No fixed points found.')
    for fp, res in fps:
        J   = numerical_jacobian(vf, fp)
        s_i = stability_analysis(J)
        print(f'  x*  = {np.round(fp, 4)}   ||f(x*)|| = {res:.2e}   stability: {s_i["label"]}')
        print(f'  λ   = {np.round(s_i["eigenvalues"], 4)}')

── FIM-ODE — LV Competition (ID 26) ──
  x*  = [0.7692 1.0769]   ||f(x*)|| = 2.15e-01   stability: saddle
  λ   = [ 0.0303+0.j -2.0174+0.j]
  x*  = [0.4615 1.3846]   ||f(x*)|| = 2.15e-01   stability: saddle
  λ   = [ 0.1075+0.j -1.5373+0.j]
  x*  = [-0.1538  2.    ]   ||f(x*)|| = 2.18e-01   stability: stable node
  λ   = [-1.4659+0.j -2.2542+0.j]
  x*  = [1.0769 0.4615]   ||f(x*)|| = 2.39e-01   stability: stable node
  λ   = [-1.3092+0.j -0.0502+0.j]

── FIM-ODE — CDIMA (ID 42) ──
  x*  = [1.6923 4.1538]   ||f(x*)|| = 3.70e-01   stability: unstable spiral
  λ   = [0.5919+2.1629j 0.5919-2.1629j]
  x*  = [2.     4.4615]   ||f(x*)|| = 5.25e-01   stability: unstable spiral
  λ   = [0.5834+1.6939j 0.5834-1.6939j]
  x*  = [1.3846 3.9248]   ||f(x*)|| = 6.53e-01   stability: stable spiral
  λ   = [-0.5065+2.2871j -0.5065-2.2871j]
  x*  = [2.     4.1538]   ||f(x*)|| = 6.76e-01   stability: unstable spiral
  λ   = [0.4716+1.7578j 0.4716-1.7578j]

── FIM-ODE — Pendulum (ID 28) ──
  x*  = [-0.0613 -0.0686]   ||f(x*)|| = 9.05e-03   stability: unstable spiral
  λ   = [0.0361+0.1534j 0.0361-0.1534j]
  x*  = [ 0.1538 -0.0091]   ||f(x*)|| = 3.59e-02   stability: unstable spiral
  λ   = [0.1119+0.3206j 0.1119-0.3206j]
  x*  = [-0.0769  0.1538]   ||f(x*)|| = 9.86e-02   stability: unstable spiral
  λ   = [0.1644+0.6794j 0.1644-0.6794j]
  x*  = [0.1538 0.1538]   ||f(x*)|| = 1.08e-01   stability: unstable spiral
  λ   = [0.1205+0.4795j 0.1205-0.4795j]

6. Stability comparison

The table below compares the fixed points found by FIM-ODE against the ground truth.

header = f"{'System':<22} {'Model':<12} {'Fixed point':<22} {'Stability':<22} {'max Re(λ)':>10} {'||f(x*)||':>12}"
print(header)
print('-' * len(header))

for sid in SYSTEM_IDS:
    name = f'{SYSTEM_NAMES[sid]} ({sid})'

    gt_rows = [
        dict(fp=r['fp'], label=r['label'], max_real=r['max_real'], res=float('nan'))
        for r in gt_results[sid]
    ]
    fim_rows = [
        dict(fp=fp,
             label=stability_analysis(numerical_jacobian(fim_results[sid]['vf'], fp))['label'],
             max_real=stability_analysis(numerical_jacobian(fim_results[sid]['vf'], fp))['max_real'],
             res=res)
        for fp, res in fim_results[sid]['fps']
    ]

    for model_name, rows in [('GT', gt_rows), ('FIM-ODE', fim_rows)]:
        for i, r in enumerate(rows):
            tag   = name       if i == 0 else ''
            mname = model_name if i == 0 else ''
            fp_s  = '[' + ', '.join(f'{v:.3f}' for v in r['fp']) + ']'
            res_s = f"{r['res']:.2e}" if not np.isnan(r['res']) else '  (exact)'
            print(f"{tag:<22} {mname:<12} {fp_s:<22} {r['label']:<22} {r['max_real']:>+10.4f} {res_s:>12}")
    print()
System                 Model        Fixed point            Stability               max Re(λ)    ||f(x*)||
---------------------------------------------------------------------------------------------------------
LV Competition (26)    GT           [0.000, 0.000]         unstable node             +3.0000      (exact)
                                    [0.000, 2.000]         stable node               -1.0000      (exact)
                                    [1.000, 1.000]         saddle                    +0.4142      (exact)
                                    [3.000, 0.000]         stable node               -1.0000      (exact)
LV Competition (26)    FIM-ODE      [0.769, 1.077]         saddle                    +0.0303     2.15e-01
                                    [0.462, 1.385]         saddle                    +0.1075     2.15e-01
                                    [-0.154, 2.000]        stable node               -1.4659     2.18e-01
                                    [1.077, 0.462]         stable node               -0.0502     2.39e-01

CDIMA (42)             GT           [1.780, 4.168]         unstable spiral           +0.2415      (exact)
CDIMA (42)             FIM-ODE      [1.692, 4.154]         unstable spiral           +0.5919     3.70e-01
                                    [2.000, 4.462]         unstable spiral           +0.5834     5.25e-01
                                    [1.385, 3.925]         stable spiral             -0.5065     6.53e-01
                                    [2.000, 4.154]         unstable spiral           +0.4716     6.76e-01

Pendulum (28)          GT           [0.000, 0.000]         centre / marginal         +0.0000      (exact)
                                    [3.142, 0.000]         saddle                    +0.9487      (exact)
Pendulum (28)          FIM-ODE      [-0.061, -0.069]       unstable spiral           +0.0361     9.05e-03
                                    [0.154, -0.009]        unstable spiral           +0.1119     3.59e-02
                                    [-0.077, 0.154]        unstable spiral           +0.1644     9.86e-02
                                    [0.154, 0.154]         unstable spiral           +0.1205     1.08e-01

7. Phase portraits

For each system we show a 1 × 2 figure (Ground Truth | FIM-ODE).

Style matches the paper figures:

  • Gray streamlines — the inferred (or true) vector field.

  • Green trajectory — integrated from initial condition 1.

  • Tomato trajectory — integrated from initial condition 2.

  • Black × (FIM-ODE panel only) — the noisy context observations.

  • Red ★ — fixed points found by the respective model.

  • Fixed point coordinates and stability labels are annotated below each panel.

COLORS  = ['#D55E00', '#56B4E9']   
N_GRID  = 25


def _streamplot(ax, vf, xlim, ylim):
    x0g = np.linspace(*xlim, N_GRID)
    x1g = np.linspace(*ylim, N_GRID)
    X0, X1 = np.meshgrid(x0g, x1g)
    pts = np.stack([X0.ravel(), X1.ravel()], axis=-1)
    try:
        dX = vf(pts)
        U  = dX[:, 0].reshape(N_GRID, N_GRID)
        V  = dX[:, 1].reshape(N_GRID, N_GRID)
        ax.streamplot(x0g, x1g, U, V, color='#bbbbbb', density=0.9,
                      linewidth=1.5, arrowsize=1.5)
    except Exception as e:
        ax.text(0.5, 0.5, f'VF error:\n{e}', transform=ax.transAxes,
                ha='center', va='center', fontsize=8)


def _draw_panel(ax, trajs, fps=None, noisy_ctx=None):
    if noisy_ctx is not None:
        ax.scatter(noisy_ctx[:, 0], noisy_ctx[:, 1],
                   s=50, c='black', marker='x', linewidths=2, alpha=0.6, zorder=3)
    for k, traj in enumerate(trajs):
        if traj is None or np.any(np.isnan(traj)):
            continue
        c = COLORS[k]
        ax.plot(traj[:, 0], traj[:, 1], color=c, lw=3, alpha=0.9,
                zorder=4, solid_capstyle='round')
        ax.scatter(traj[:, 0], traj[:, 1], s=8, color='black', alpha=0.35, zorder=5)
        ax.scatter(traj[0, 0], traj[0, 1], s=280, marker='s',
                   facecolor=c, edgecolors='black', linewidths=2, zorder=6)
        ax.text(traj[0, 0], traj[0, 1], str(k), fontsize=10,
                ha='center', va='center', color='white', fontweight='bold', zorder=7)
    if fps:
        for fp, _ in fps:
            ax.scatter(fp[0], fp[1], s=220, c='red', marker='*',
                       edgecolors='k', linewidths=0.5, zorder=8)


def _fp_annotation(ax, fps, fontsize=8):
    """Write fixed-point coordinates and stability below the panel."""
    if not fps:
        ax.text(0.0, -0.06, 'no fixed points found',
                transform=ax.transAxes, ha='left', va='top',
                fontsize=fontsize, style='italic', color='gray', clip_on=False)
        return
    for i, (fp, label) in enumerate(fps):
        coord = '(' + ', '.join(f'{v:.2f}' for v in fp) + ')'
        ax.text(0.0, -0.06 - i * 0.065, f'\u2605 FP{i}: {coord}  [{label}]',
                transform=ax.transAxes, ha='left', va='top',
                fontsize=fontsize, color='black', clip_on=False)


# Build GT vector-field callables from the ODEBench equations
def _make_gt_vf(eq_str, consts, D=2):
    eq_str = eq_str.replace('^', '**')
    xs = [sp.Symbol(f'x_{i}') for i in range(D)]
    cs = {sp.Symbol(f'c_{i}'): float(v) for i, v in enumerate(consts)}
    parts = [sp.sympify(p.strip()).subs(cs) for p in eq_str.split('|')]
    funcs = [sp.lambdify(xs, expr, modules='numpy') for expr in parts]
    def vf(x):
        cols = [x[:, i] for i in range(D)]
        outs = []
        for func in funcs:
            v = func(*cols)
            if np.isscalar(v): v = np.full(x.shape[0], float(v))
            outs.append(np.asarray(v, dtype=float))
        return np.stack(outs, axis=-1)
    return vf

gt_vfs = {sid: _make_gt_vf(systems[sid]['meta']['eq'], systems[sid]['meta']['consts'])
          for sid in SYSTEM_IDS}

# Pre-compute FIM-ODE trajectories from clean ICs for every system
fim_phase_preds = {}
for sid in SYSTEM_IDS:
    ctx_traj = systems[sid]['trajs']            # (1, T_ctx, 2)
    ctx_ts   = systems[sid]['times'][0, :, 0]   # (T_ctx,)
    cs       = clean_systems[sid]
    preds    = []
    for k in range(min(2, cs['trajs'].shape[0])):
        y0  = cs['trajs'][k, 0]
        tev = cs['times'][k, :, 0]
        try:
            preds.append(integrate_from_context(model, ctx_traj, ctx_ts, y0, tev, device))
        except RuntimeError as e:
            print(f'  system {sid} IC{k}: {e}')
            preds.append(None)
    fim_phase_preds[sid] = preds

# One figure per system
for sid in SYSTEM_IDS:
    name  = SYSTEM_NAMES[sid]
    cs    = clean_systems[sid]
    noisy = systems[sid]['trajs'][0]   # (T_ctx, 2) — the noisy context traj

    gt_trajs  = [cs['trajs'][k] for k in range(min(2, cs['trajs'].shape[0]))]
    fim_trajs = fim_phase_preds[sid]

    # Shared axis limits derived from all trajectories
    all_t = [t for t in gt_trajs + fim_trajs if t is not None]
    all_x0 = np.concatenate([t[:, 0] for t in all_t])
    all_x1 = np.concatenate([t[:, 1] for t in all_t])
    pad0 = max((all_x0.max() - all_x0.min()) * 0.15, 0.5)
    pad1 = max((all_x1.max() - all_x1.min()) * 0.15, 0.5)
    xlim = (float(all_x0.min() - pad0), float(all_x0.max() + pad0))
    ylim = (float(all_x1.min() - pad1), float(all_x1.max() + pad1))

    gt_fps  = [(tuple(r['fp']), r['label']) for r in gt_results[sid]]
    fim_fps = [(tuple(fp),
                stability_analysis(numerical_jacobian(fim_results[sid]['vf'], fp))['label'])
               for fp, _ in fim_results[sid]['fps']]

    fig, axes = plt.subplots(1, 2, figsize=(12, 5))
    plt.subplots_adjust(left=0.07, right=0.97, top=0.88, bottom=0.22, wspace=0.18)
    fig.suptitle(f'{name}  (ID {sid})', fontsize=13, fontweight='bold')

    for ax, vf, panel_title in zip(axes,
                                    [gt_vfs[sid], fim_results[sid]['vf']],
                                    ['Ground Truth', 'FIM-ODE']):
        _streamplot(ax, vf, xlim, ylim)
        ax.set_xlim(xlim)
        ax.set_ylim(ylim)
        ax.set_aspect('equal', adjustable='box')
        ax.set_xlabel('$x_0$', fontsize=10)
        ax.set_ylabel('$x_1$', fontsize=10)
        ax.set_title(panel_title, fontsize=12, fontweight='bold')

    _draw_panel(axes[0], gt_trajs,  fps=gt_fps)
    _draw_panel(axes[1], fim_trajs, fps=fim_fps, noisy_ctx=noisy)

    fig.canvas.draw()
    _fp_annotation(axes[0], gt_fps)
    _fp_annotation(axes[1], fim_fps)

    plt.show()
<Figure size 1200x500 with 2 Axes>
<Figure size 1200x500 with 2 Axes>
<Figure size 1200x500 with 2 Axes>

8. Motion Capture: CMU MoCap Subject 09

We now apply FIM-ODE to real motion-capture data from the CMU MoCap database, following the evaluation protocol of Hegde et al. (UAGP-ODE).

Data pipeline:

  • Raw 50D joint-angle time series are dimensionality-reduced to 5D via PCA fitted on the training set, then normalized per component to unit std.

  • FIM-ODE is limited to \leq 3$, so we feed it the first 3 PCA components.

  • Predictions are integrated from the test initial conditions and back-projected to 50D joint-angle space for evaluation.

We show two context lengths for Subject 09:

VariantTraining trajectoriesTrain lengthTest trajectoriesTest length
short6 × 50 steps0.5 s2 × 120 steps1.2 s
long6 × 100 steps1.0 s2 × 120 steps1.2 s
from ode_tutorial_helper import mocap_pca_to_50d, plot_mocap_pca

# Download preprocessed subject-09 data from HuggingFace (cached after first run)
mocap_short_path = hf_hub_download(
    repo_id='FIM4Science/fim-ode-mocap-tutorial',
    filename='mocap09_short.npz',
    repo_type='dataset',
)
mocap_long_path = hf_hub_download(
    repo_id='FIM4Science/fim-ode-mocap-tutorial',
    filename='mocap09_long.npz',
    repo_type='dataset',
)

def load_mocap_npz(path):
    d = np.load(path)
    return {
        'trn_ys':         d['trn_ys'],          # (n_trn, T_trn, 5) normalized PCA
        'trn_ts':         d['trn_ts'],          # (T_trn,)
        'tst_ys':         d['tst_ys'],          # (n_tst, T_tst, 5) normalized PCA
        'tst_ts':         d['tst_ts'],          # (T_tst,)
        'pca_components': d['pca_components'],  # (5, 50)
        'pca_data_mean':  d['pca_data_mean'],   # (50,)
        'norm_mean':      d['norm_mean'],        # (1,1,5)
        'norm_std':       d['norm_std'],         # (1,1,5)
    }

mocap = {
    'short': load_mocap_npz(mocap_short_path),
    'long':  load_mocap_npz(mocap_long_path),
}

for v, d in mocap.items():
    print(f'{v:6s}  trn: {d["trn_ys"].shape}  tst: {d["tst_ys"].shape}')
short   trn: (6, 50, 5)  tst: (2, 120, 5)
long    trn: (6, 100, 5)  tst: (2, 120, 5)

8.1 Integrating test trajectories

We condition FIM-ODE on all training trajectories (first 3 PCA components), then integrate from each test initial condition using RK45.

N_PCA     = 3   # FIM-ODE is limited to D ≤ 3
N_TEST    = 2   # number of test trajectories to show
preds_mocap = {}

for variant, d in mocap.items():
    ctx_traj = d['trn_ys'][:, :, :N_PCA]   # (n_trn, T_trn, 3)
    ctx_ts   = d['trn_ts']
    tst_ts   = d['tst_ts']

    preds = []
    for i in range(min(N_TEST, d['tst_ys'].shape[0])):
        y0   = d['tst_ys'][i, 0, :N_PCA].astype(float)
        pred = integrate_from_context(model, ctx_traj, ctx_ts, y0, tst_ts, device)
        preds.append(pred)   # (T_tst, 3)
    preds_mocap[variant] = preds
    print(f'{variant:6s}  integrated {len(preds)} test trajectories  '
          f'shape: {preds[0].shape}')
short   integrated 2 test trajectories  shape: (120, 3)
long    integrated 2 test trajectories  shape: (120, 3)

8.2 PCA projection plots

Each panel shows a 2D projection of the 3D PCA space.

  • Light blue lines: training context trajectories

  • Black ×: ground-truth test trajectories

  • Green lines + square: FIM-ODE prediction from the test IC

for variant, d in mocap.items():
    fig = plot_mocap_pca(
        trn_ys = d['trn_ys'][:, :, :N_PCA],
        tst_ys = d['tst_ys'][:, :, :N_PCA],
        preds  = preds_mocap[variant],
        title  = f'Subject 09 — {variant} variant (zero-shot FIM-ODE)',
        n_test = N_TEST,
    )
    plt.show()
<Figure size 1400x500 with 3 Axes>
<Figure size 1400x500 with 3 Axes>

8.3 Zero-shot MSE (50D joint-angle space)

We back-project the 3D predictions to the full 50D joint-angle space (padding unmodelled PCA components 4–5 with zeros) and compute MSE against the ground-truth 50D trajectories.

print(f'  {"Variant":<10} {"n_trn":>6} {"T_trn":>6} {"n_tst":>6} {"T_tst":>6} {"MSE (50D)":>12}')
print('  ' + '-' * 48)

for variant, d in mocap.items():
    tst_ys_5d = d['tst_ys']   # (n_tst, T_tst, 5) — full 5D normalized PCA
    kw = dict(
        pca_components = d['pca_components'],
        pca_data_mean  = d['pca_data_mean'],
        norm_mean      = d['norm_mean'],
        norm_std       = d['norm_std'],
    )

    mses = []
    for i, pred_3d in enumerate(preds_mocap[variant]):
        pred_50d = mocap_pca_to_50d(pred_3d, n_dims=N_PCA, **kw)   # (T_tst, 50)
        true_50d = mocap_pca_to_50d(tst_ys_5d[i], n_dims=5, **kw)  # (T_tst, 50)
        mses.append(float(np.mean((pred_50d - true_50d) ** 2)))

    mse = float(np.mean(mses))
    n_trn, T_trn = d['trn_ys'].shape[:2]
    n_tst, T_tst = d['tst_ys'].shape[:2]
    print(f'  {variant:<10} {n_trn:>6d} {T_trn:>6d} {n_tst:>6d} {T_tst:>6d} {mse:>12.5f}')
  Variant     n_trn  T_trn  n_tst  T_tst    MSE (50D)
  ------------------------------------------------
  short           6     50      2    120      6.10087
  long            6    100      2    120      7.34162

Summary

With a single forward pass, FIM-ODE recovers vector fields that:

  • Match the ground-truth flow topology on ODEBench systems (correct qualitative structure)

  • Locate fixed points to within a fraction of a unit and classify their stability

  • Track real motion-capture trajectories in 3D PCA space with low MSE in the full 50D signal

This is achieved without any parameter fitting at test time, using only a few noisy or short-context trajectories as input.