Personalization: Parkinson’s disease progression and inference modeling with Leaspy#

This example walks through the core Leaspy workflow on a synthetic Parkinson’s disease dataset:

  1. Fit a shared progression model on a training cohort.

  2. Personalize the model to new patients — estimating where each one sits on the shared disease timeline and how fast they are progressing.

  3. Reconstruct and predict individual trajectories from those two numbers alone.

The key concept is personalization: once a model is trained, a new patient needs only a handful of visits for Leaspy to estimate their individual parameters (τ, ξ) and predict their future course.

We load a synthetic dataset of Parkinson’s patients with repeated measurements of three MDS-UPDRS motor subscores over time.

from leaspy.datasets import load_dataset
from leaspy.io.data import Data

df = load_dataset("parkinson")
MDS1_total MDS2_total MDS3_off_total SCOPA_total MOCA_total REM_total PUTAMEN_R PUTAMEN_L CAUDATE_R CAUDATE_L
ID TIME
GS-001 71.354607 0.112301 0.122472 0.171078 0.160001 0.275257 0.492485 0.780210 0.676774 0.622611 0.494641
71.554604 0.140880 0.109504 0.118693 0.135852 0.380934 0.577203 0.751444 0.719796 0.618434 0.530978
72.054604 0.225499 0.270502 0.061310 0.211134 0.351172 0.835828 0.823315 0.691504 0.717099 0.576978
73.054604 0.132519 0.253548 0.258786 0.245323 0.377842 0.566496 0.813593 0.787914 0.770048 0.709486
73.554604 0.278923 0.321920 0.143350 0.223102 0.292768 0.741811 0.888792 0.852720 0.797368 0.715465


n_subjects = df.index.get_level_values("ID").unique().shape[0]
print(f"{n_subjects} subjects in the dataset.")
200 subjects in the dataset.

We split into a training cohort and a held-out test cohort. The model is fitted on training subjects; we then personalize it to test subjects as if they were new patients arriving at a clinic.

df_train = df.loc[:"GS-160"][["MDS1_total", "MDS2_total", "MDS3_off_total"]]
df_test  = df.loc["GS-161":][["MDS1_total", "MDS2_total", "MDS3_off_total"]]

data_train = Data.from_dataframe(df_train)
data_test  = Data.from_dataframe(df_test)

We use a multivariate logistic model: all three scores share a single sigmoidal trajectory, and patients differ only in when and how fast they travel along it.

from leaspy.models import LogisticModel

model = LogisticModel(name="test-model", source_dimension=2)
import matplotlib.pyplot as plt
from leaspy.io.logs.visualization.plotting import Plotting

leaspy_plotting = Plotting(model)

Raw training observations. The scores look heterogeneous because each patient is at a different disease stage and progresses at a different pace.

ax = leaspy_plotting.patient_observations(data_train, alpha=0.7, figsize=(14, 6))
ax.set_ylim(0, 0.8)
plt.show()
Observations

Fitting learns a single population-level sigmoidal curve that best explains all training subjects simultaneously.

model.fit(data_train, "mcmc_saem", seed=0, n_iter=100, progress_bar=False)
Fit with `AlgorithmName.FIT_MCMC_SAEM` took: 1.58s

The average trajectory is the shared progression curve. Every patient is assumed to follow this same curve, shifted and rescaled in time.

ax = leaspy_plotting.average_trajectory(alpha=1, figsize=(14, 6), n_std_left=2, n_std_right=8)
plt.show()
Average trajectories
Personalization estimates two individual parameters per test patient from their visits:

τ (tau) — disease onset age (position on the timeline) ξ (xi) — log-acceleration (pace of progression)

ip = model.personalize(data_test, "scipy_minimize", seed=0, progress_bar=False)
ip.to_dataframe().head()
Personalize with `AlgorithmName.PERSONALIZE_SCIPY_MINIMIZE` took: 13.02s
sources_0 sources_1 tau xi
ID
GS-161 0.063620 -0.367563 57.699699 -0.294230
GS-162 -0.129612 -0.521236 69.829018 -0.007538
GS-163 -0.185647 -0.260171 65.209030 0.136339
GS-164 2.287313 0.234134 41.032227 0.878976
GS-165 0.384619 -1.821668 63.588161 0.433673


For example for the patient with ID GS-161 we observe a tau`of 57.69 and a xi of -0.29 (let’s ignore the sources parameters for the moment). To interpret the patient’s tau we should compare it with the population-level tau_mean.

model.parameters['tau_mean']
tensor([67.3562])

The average patient reaches the inflection point of the disease trajectory at age 67.35. The patient GS-161 has a tau of 57.69, which means that they are showing an earlier disease onset by approximately 10 years on the reparametrized disease timeline.

To interpret the patient’s xi we should compare it with 0. Patient GS-161 has a xi of -0.29, which means that they are progressing slower than the average patient, while patient GS-163 has a xi of 0.13 which means that they are progressing faster than the average patient. %% After time reparametrization ψᵢ(t) = exp(ξᵢ)·(t − τᵢ), all patients align onto the same curve — confirming the model has captured their individual stages and speeds.

ax = leaspy_plotting.patient_observations_reparametrized(
    data_test, ip, alpha=0.7, linestyle="-", figsize=(14, 6)
)
plt.show()
Observations

Without reparametrization the same data looks scattered: patients of the same chronological age may be at very different disease stages.

ax = leaspy_plotting.patient_observations(data_test, alpha=0.7, linestyle="-", figsize=(14, 6))
plt.show()
Observations

To illustrate prediction we pick one test patient. model.estimate is the low-level API that returns predicted scores at arbitrary timepoints — useful for custom analyses.

import numpy as np

print(f"Seen ages: {df_test.loc['GS-187'].index.values}")
print("Individual parameters:", ip["GS-187"])

timepoints = np.linspace(60, 100, 100)
reconstruction = model.estimate({"GS-187": timepoints}, ip)
print(f"Predicted scores at age 80: {reconstruction['GS-187'][40]}")  # index 40 ≈ age 80
Seen ages: [61.34811783 62.34811783 63.84811783 64.34812164 67.84812164 68.34812164
 69.34812164 69.84812164 70.84812164 71.34812164 71.84812164 72.34812164
 72.84812164 73.34812164]
Individual parameters: {'sources': [1.2316123247146606, 0.5551508665084839], 'tau': [73.25882720947266], 'xi': [0.06532679498195648]}
Predicted scores at age 80: [0.13828589 0.23553264 0.2740034 ]

patient_trajectories wraps that same call and overlays the predicted curve on the observed visits, extrapolating beyond the last observation.

ax = leaspy_plotting.patient_trajectories(
    data_test, ip,
    patients_idx=["GS-187"],
    labels=["MDS1", "MDS2", "MDS3 (off)"],
    figsize=(16, 6),
    factor_future=5,
)
ax.set_xlim(45, 120)
plt.show()
Observations and individual trajectories

From a fitted model and just a few visits, Leaspy reduces each patient to (τ, ξ) — two numbers that place them on a shared disease timeline and predict their future trajectory across all scores simultaneously.

Interpreting the individual space shifts. Beyond (τᵢ, ξᵢ), each patient also has a spatial signature — per-feature offsets that describe whether they are more or less affected on certain features than the average trajectory at tau_mean. These offsets are the space shifts wᵢ,ₖ:

  • wᵢ,ₖ has one entry per feature, returned as columns w_<feature>.

  • A positive w_MDS1 for patient i means “given this patient’s (τ, ξ), they are more impaired on MDS1 than the average trajectory predicts”; negative means less impaired.

  • By construction, the average wᵢ,ₖ is approximately zero.

ip.compute_space_shifts(model).head()
w_MDS1_total w_MDS2_total w_MDS3_off_total
GS-161 0.010583 -0.000690 -0.021277
GS-162 0.018251 -0.005835 -0.025706
GS-163 0.010894 -0.005584 -0.010374
GS-164 -0.041088 0.051860 -0.033738
GS-165 0.051427 -0.001890 -0.106857


Large |wᵢ,ₖ| flags patients whose feature k is atypically ahead or behind their overall stage — a signal worth a closer look (alternative diagnosis, treatment response, comorbidity). This parameter can be also interpreted “reverting” the features normalization, i.e. for the MMSE that goes from 0 to 30, a wᵢ,MMSE = -0.1 means that patient i is 3 points better than the average patient at their stage.

The next example extends this to joint models that also incorporate time-to-event outcomes: see Joint Model.

Total running time of the script: (0 minutes 16.811 seconds)

Gallery generated by Sphinx-Gallery