Twinning Procedure¶
Twinning is the step that creates a digital twin: it personalizes a blueprint by estimating the set of unknown model parameters \(\boldsymbol{\theta}_{phy}\) that best explains a person's recorded data.
How it works: MAP estimation¶
Given the physiological model
twinning estimates \(\boldsymbol{\theta}_{phy}\) by Maximum A Posteriori (MAP) estimation. Using the a priori information \(p_{\boldsymbol{\theta}}(\boldsymbol{\theta})\) on the parameters, MAP finds the \(\boldsymbol{\theta}_{phy}\) that maximizes the posterior:
Concretely, ReplayBG minimizes the negative log-posterior
(\(-(\log\text{-prior} + \log\text{-likelihood})\)) with a Powell
optimisation (scipy.optimize.minimize), run from n_starts different initial
guesses (multi-start) sampled from the priors, optionally in parallel across CPUs.
Powell itself is unbounded: the bounds are enforced by the prior, which is \(-\infty\)
outside [min, max], and the final estimate is rounded (integer parameters) and
clipped to the bounds. Start i is sampled with seed i, so twinning is
deterministic.
Each start runs at scipy's default tolerance, which stops early on the sloppy
physiological likelihood, so the n_polish best starts (default 8) are then
polished: Powell is restarted from each of them with tight tolerances
(xtol = ftol = 1e-8) until the objective stops improving, for at most 5 restarts
of at most min(1000 × n_params, 8000) evaluations each. Starts whose objective
is within 1e-6 of an already-selected one are skipped as duplicates, and failed
(non-finite) starts are never polished. The best polished result is kept.
Polishing several starts rather than only the best one matters because the raw
ranking is a poor guide to which start polishes into the best optimum. With
parallelize=True the polishes also run in parallel, on up to
min(n_jobs, n_polish) processes. n_polish=1 polishes only the best start (the
behaviour before 2.0.0b3); polishing can be switched off entirely only by using
Twinner directly with polish=False.
The likelihood uses a Gaussian error model with a constant 5% coefficient of variation.
Only MAP — no MCMC
The current ReplayBG uses MAP only. It produces a single point estimate
theta (the digital twin). The old py_replay_bg's MCMC method and the
multiple-realizations machinery are not part of this codebase.
How to twin¶
Twinning is performed by ReplayBG.twin():
result = rbg.twin(
rbg_data=rbg_data,
model=model,
unknown_parameters_prior=unknown_parameters_prior,
correlations=None,
n_starts=64,
n_polish=8,
parallelize=True, n_jobs=-1, # default: parallelize=False, n_jobs=None (all CPUs)
log_history=False,
path=None, save_name=None,
)
theta = result['theta'] # {name: value} — the digital twin
Guard multiprocessing
parallelize=True uses multiprocessing, so the whole script must sit behind
if __name__ == '__main__':, calling freeze_support() (from
multiprocessing) at the top of the guarded block.
The unknown_parameters_prior dict¶
This dict declares which parameters to estimate and the prior belief about each. Each entry maps a parameter name to:
{
'param_name': {
'prior': <distribution>, # Normal / LogNormal / Gamma / Uniform
'min': float, # lower bound
'max': float, # upper bound
'integer': bool, # optional, default False
},
...
}
The prior is one of the distributions — each provides
evaluate(x) (the pdf), log_pdf(x), cdf(x) and sample(min, max, seed);
twinning uses log_pdf and cdf for the truncated log-prior and sample for the
starts. Only the parameters you list are estimated;
every other model parameter keeps its physiological default. So if your data has
no lunch, simply omit kabs_L / beta_L.
Correlated priors (Gaussian copula)¶
You can encode prior correlations between parameters via the correlations
argument — pairwise {(name_a, name_b): rho} with rho in [-1, 1]:
correlations = {('SI_B', 'SI_D'): .53, ('SI_B', 'SI_L'): .46, ('SI_L', 'SI_D'): .46,
('Gb', 'SI_B'): -.35, ('Gb', 'SI_L'): -.30, ('Gb', 'SI_D'): -.44}
result = rbg.twin(..., correlations=correlations)
The joint prior becomes a Gaussian copula over the named parameters: their
marginals stay exactly as declared, and only the dependence structure is added.
Every prior must provide a cdf method (it is used for the truncation
correction and for the copula; all built-in distributions qualify). Passing None treats parameters as independent. See
docs/gaussian_copula_priors.pdf in the repo for the math.
Twinning single portions of data¶
To twin a single meal or a single day, build the blueprint and call twin(). This
single-meal example (see
example/twin_single_meal.py)
also records the optimisation history and saves the results:
from multiprocessing import freeze_support
import os, pandas as pd
from py_replay_bg.data.single_meal_t1d_data import SingleMealT1DData
from py_replay_bg.model.single_meal_t1d import SingleMealT1DModel
from py_replay_bg.distributions import Normal, Gamma, LogNormal, Uniform
from py_replay_bg import ReplayBG
if __name__ == '__main__':
freeze_support()
df = pd.read_parquet("data_day_1.parquet"); df['t'] = pd.to_datetime(df['t'])
df.loc[10, 'cho'] = 10; df.loc[10, 'cho_label'] = 'B' # restore the missing breakfast
save_folder = os.path.join(os.path.abspath(''), 'results')
rbg = ReplayBG()
rbg_data = SingleMealT1DData(data=df, body_weight=100, environment=rbg.environment)
model = SingleMealT1DModel(u2ss=rbg_data.u2ss, tsteps=rbg_data.tsteps)
unknown_parameters_prior = {
'Gb': {'prior': Normal(mu=119.13, sigma=7.11), 'min': 70, 'max': 150},
'SG': {'prior': LogNormal(mu=-3.8, sigma=0.5), 'min': 0, 'max': .5},
'p2': {'prior': Normal(mu=0.11, sigma=0.004), 'min': 0, 'max': .5},
'f': {'prior': Normal(mu=0.8, sigma=0.05), 'min': 0, 'max': 1},
'ka2': {'prior': LogNormal(mu=-4.2875, sigma=0.4274), 'min': 0, 'max': .5},
'kd': {'prior': LogNormal(mu=-3.5090, sigma=0.6187), 'min': 0, 'max': .5},
'kempt': {'prior': LogNormal(mu=-1.9646, sigma=0.7069), 'min': 0, 'max': .75},
'SI': {'prior': Gamma(alpha=3.3, beta=1 / 5e-4), 'min': 0, 'max': .1},
'kabs': {'prior': LogNormal(mu=-5.4591, sigma=1.4396), 'min': 0, 'max': .5},
'beta': {'prior': Uniform(a=0, b=60), 'min': 0, 'max': 60, 'integer': True},
}
result = rbg.twin(rbg_data=rbg_data, model=model,
unknown_parameters_prior=unknown_parameters_prior,
parallelize=True, n_jobs=-1, n_starts=16, log_history=True,
path=save_folder, save_name='single_meal_day_1')
The multi-meal and multi-meal extended variants are identical apart from the blueprint classes and the parameters you estimate:
from py_replay_bg.data.multi_meal_t1d_data import MultiMealT1DData
from py_replay_bg.model.multi_meal_t1d import MultiMealT1DModel
rbg_data = MultiMealT1DData(data=df, environment=rbg.environment)
t_start = int((df["t"].iloc[0] - df["t"].iloc[0].normalize()).total_seconds() / 60)
model = MultiMealT1DModel(u2ss=rbg_data.u2ss, tsteps=rbg_data.tsteps, t_start=t_start)
# estimate e.g. Gb, SG, f, p2, ka2, kempt, SI_B/L/D, kabs_B/L/D/S, beta_B/L/D/S
from py_replay_bg.data.multi_meal_extended_t1d_data import MultiMealExtendedT1DData
from py_replay_bg.model.multi_meal_extended_t1d import MultiMealExtendedT1DModel
rbg_data = MultiMealExtendedT1DData(data=df, environment=rbg.environment)
t_start = int((df["t"].iloc[0] - df["t"].iloc[0].normalize()).total_seconds() / 60)
model = MultiMealExtendedT1DModel(u2ss=rbg_data.u2ss, tsteps=rbg_data.tsteps, t_start=t_start)
# additionally estimate kabs_B2/L2/S2, beta_B2/L2/S2, SI_B2
See example/twin_multi_meal.py
and example/twin_multi_meal_extended.py.
Twinning multi-day intervals¶
To twin a recording spanning more than one day, twin it one day at a time and chain the days: the final physiological state of each day becomes the initial condition of the next. This removes the steady-state assumption for every day after the first.
The key mechanism: after fitting a day, simulate the model forward to its final
state, then read off the carry-over conditions with model.get_final_x0() (the
end state) and model.get_theta() (the parameters used to rescale that state).
Pass them into the next day's model as x0 and theta_prev.
from py_replay_bg.data.multi_meal_t1d_data import MultiMealT1DData
from py_replay_bg.model.multi_meal_t1d import MultiMealT1DModel
from py_replay_bg.utils.plot_twinning import _simulate
def split_days(df):
"""Split a recording into daily segments at every 04:00 (the SI_D→SI_B boundary)."""
marks = [i for i in range(len(df))
if df['t'].dt.hour.iloc[i] == 4 and df['t'].dt.minute.iloc[i] == 0]
bounds = [0] + marks + [len(df)]
return [df.iloc[a:b].reset_index(drop=True) for a, b in zip(bounds[:-1], bounds[1:])]
day_dfs = split_days(df)
prev_x0 = None
prev_theta = None
for n, day_df in enumerate(day_dfs, start=1):
rbg_data = MultiMealT1DData(data=day_df, environment=rbg.environment)
t_start = int((day_df["t"].iloc[0] - day_df["t"].iloc[0].normalize()).total_seconds() / 60)
# Day 1 is a cold start; later days inherit the carry-over state + previous theta.
if prev_x0 is None:
model = MultiMealT1DModel(u2ss=rbg_data.u2ss, tsteps=rbg_data.tsteps, t_start=t_start)
else:
model = MultiMealT1DModel(u2ss=rbg_data.u2ss, tsteps=rbg_data.tsteps,
t_start=t_start, x0=prev_x0, theta_prev=prev_theta)
result = rbg.twin(rbg_data=rbg_data, model=model,
unknown_parameters_prior=unknown_parameters_prior,
parallelize=True, n_jobs=-1, n_starts=8,
path='results', save_name=f'multi_meal_intervals_day{n}')
# Advance the fitted model to its final state, then read the carry-over.
_simulate(model, rbg_data, result['theta'])
prev_x0 = model.get_final_x0()
prev_theta = model.get_theta()
The full runnable version — including a carry-over continuity check and a stitched
multi-day plot — is
example/twin_multi_meal_intervals.py.
Use the same u2ss across the interval
Cutting at 04:00 keeps each day's B/L/D/S meal structure and the overnight
SI_D window inside a single segment. Keep the same steady-state basal u2ss
across the interval so the equilibrium is consistent from day to day.
What is returned and saved¶
twin() returns {'theta', 'correlations', 'history'}. With log_history=True,
history is a dict of per-evaluation lists with keys theta, log_prior,
log_likelihood and log_posterior, covering both the multi-start and the polish
evaluations (merged from the worker processes in parallel mode); otherwise it is
None. When path is given, it
pickles {theta, correlations, history, rbg_data} to
<path>/twin_<save_name>.pkl. See Saving Results, and
Analyzing Results for folding the AGATA fit metrics back
into the saved file.