Structural Identifiability of a Harmonic Oscillator#
Consider the undamped harmonic oscillator
where \(m>0\) is the mass, \(k>0\) is the spring constant, and the initial state \((x_0,v_0)\) is known. Its position is
The complete position trajectory therefore depends on \(m\) and \(k\) only through the ratio \(k/m\). The parameter-to-observable map is unchanged along every admissible curve \(k=\omega^2m\), so position data cannot identify \(m\) and \(k\) separately. This is structural nonidentifiability: it follows from the parameter-to-observable map itself, before a data set or inference algorithm is chosen (Raue et al., 2009).
Statistical model#
We observe position at times \(t_1,\ldots,t_N\) and use the model
The unknown vector is \(\theta=(m,k,\sigma)\), while the initial state is fixed at \(x_0=1\) and \(v_0=0\). We assign independent priors
These proper priors produce a proper posterior, but they do not restore information that is absent from the likelihood: the posterior for \((m,k)\) retains a ridge along nearly constant \(k/m\) (Stuart, 2010). The computation below illustrates this geometry.
# Containers for storing parameters
class OscillatorState(NamedTuple):
x: float
v: float
class OscillatorParams(NamedTuple):
m: float
k: float
init_cond: OscillatorState
class MeasurementParams(NamedTuple):
sigma: float
class Params(NamedTuple):
oscillator: OscillatorParams
measurement: MeasurementParams
Unconstrained coordinates#
NUTS operates most conveniently in unconstrained coordinates. We therefore define an invertible map \(T:\mathbb{R}^3\rightarrow\Theta\) and write \(\theta=T(\xi)\), where \(\Theta=(1,2)\times(0.5,3)\times(0,\infty)\) and
Gaussian cumulative-distribution transforms of \(\xi_m=\xi_1\) and \(\xi_k=\xi_2\) generate the uniform priors for \(m\) and \(k\), while \(\sigma=\exp(-3+0.5\xi_\sigma)\) with \(\xi_\sigma=\xi_3\) generates the lognormal prior.
T = ParamsTransformation(
m_low=1.0,
m_high=2.0,
k_low=0.5,
k_high=3.0,
sigma_shift=-3,
sigma_scale=0.5,
init_cond=OscillatorState(x=1.0, v=0.0)
)
The transformed samples recover the intended priors in physical coordinates. The following class collects the prior, likelihood, and predictive distributions.
prob_model = ProbabilisticDynamicalSystem(T, harmonic_oscillator)
Prior predictive trajectories#
Prior predictive trajectories show the range of motions allowed before observing data.
# 1. Sample the prior for xi (this is just a standard Gaussian)
key, subkey = jrandom.split(key)
_xi_prior_samples = prob_model.sample_prior(key=subkey, num_samples=300)
# 2. Transform xi (unconstrained) to theta (constrained)
_theta_prior_samples = vmap(T.forward)(_xi_prior_samples)
# 3. Solve the ODE system for each theta sample
times_plt = jnp.linspace(0, 10, 200)
x_plt = vmap(harmonic_oscillator, in_axes=(None, 0))(times_plt, _theta_prior_samples.oscillator).x
# 4. Plot the samples
fig, ax = plt.subplots(figsize=FIGURE_SIZES["half_standard"], constrained_layout=True)
ax.plot(times_plt, x_plt.T, lw=0.7, alpha=0.12, color="0.25", rasterized=True)
ax.set_xlabel("Time")
ax.set_ylabel("Position")
finalize_axes(keep_box=False);
Synthetic observations#
We generate observations at \(50\) equally spaced times from \(m=1.5\), \(k=1.5\), and \(\sigma=0.05\). The true ratio is therefore \(\omega^2=k/m=1\).
Posterior geometry in the original parameterization#
We sample the posterior with four NUTS chains. NUTS adapts Hamiltonian trajectory lengths automatically (Hoffman and Gelman, 2014); the implementation uses BlackJAX (Cabezas et al., 2024). Multiple chains and rank-normalized \(\widehat R\) and effective sample size diagnostics help reveal poor exploration, but they do not establish structural identifiability (Vehtari et al., 2021).
log_posterior_wrapped = partial(
prob_model.log_posterior, obs_times=obs_times, obs_positions=obs_positions
)
def run_nuts_chains(
logdensity_fn, initial_positions, *, key, num_warmup=1000, num_draws=2000
):
num_chains = initial_positions.shape[0]
warmup = blackjax.window_adaptation(
blackjax.nuts,
logdensity_fn,
is_mass_matrix_diagonal=False,
target_acceptance_rate=0.995,
)
def run_warmup(warmup_key, initial_position):
return warmup.run(warmup_key, initial_position, num_warmup)
warmup_key, sampling_key = jrandom.split(key)
warmup_keys = jrandom.split(warmup_key, num_chains)
adaptation_results, _ = jit(vmap(run_warmup))(warmup_keys, initial_positions)
kernel = blackjax.nuts.build_kernel()
def run_chain(chain_key, state, step_size, inverse_mass_matrix):
def one_step(current_state, transition_key):
next_state, info = kernel(
transition_key,
current_state,
logdensity_fn,
step_size,
inverse_mass_matrix,
)
return next_state, (next_state.position, info.is_divergent)
transition_keys = jrandom.split(chain_key, num_draws)
_, output = lax.scan(one_step, state, transition_keys)
return output
sampling_keys = jrandom.split(sampling_key, num_chains)
samples, divergences = jit(
vmap(run_chain, in_axes=(0, 0, 0, 0))
)(
sampling_keys,
adaptation_results.state,
adaptation_results.parameters["step_size"],
adaptation_results.parameters["inverse_mass_matrix"],
)
samples.block_until_ready()
return samples, divergences
initial_positions = jnp.array(
[
[-1.28, -0.71, -0.4],
[-0.39, -0.41, 0.0],
[0.39, -0.10, 0.4],
[1.28, 0.15, 0.2],
]
)
posterior_samples_nuts_xi, original_divergences = run_nuts_chains(
log_posterior_wrapped,
initial_positions,
key=jrandom.PRNGKey(20260919),
)
flat_original_samples = posterior_samples_nuts_xi.reshape((-1, T.n_params))
posterior_samples_nuts = vmap(T.forward)(flat_original_samples)
The unconstrained trace plots and diagnostics summarize how the four chains explore the posterior ridge.
| Coordinate | R-hat | Bulk ESS | Divergences |
|---|---|---|---|
| xi-m | 1.008 | 478 | 0 |
| xi-k | 1.008 | 480 | 0 |
| xi-sigma | 1.002 | 2195 | 0 |
The \(m\) and \(k\) coordinates drift slowly and remain strongly coupled as the chains move along the posterior ridge. This produces high autocorrelation and can make marginal summaries sensitive to incomplete exploration. These are practical sampling symptoms of geometry already established analytically; they are not the proof of structural nonidentifiability.
The narrow band in the \((\xi_1,\xi_2)\) panel is the ridge expressed in unconstrained coordinates. Transforming the samples back to physical coordinates makes its meaning explicit.
For these data, the true ratio is \(k/m=1\), so the physical ridge lies near \(k=m\). More generally, it follows the line \(k=\omega^2m\). If the scientific objective requires \(m\) and \(k\) separately, the experiment needs independent information, such as a known mass or an additional force-response measurement.
Reduced model#
When only the oscillation frequency matters, we can replace \((m,k)\) by the identifiable combination \(\omega^2=k/m\). We assign the reduced model a new prior, \(\omega^2\sim\operatorname{Uniform}(0.25,3)\). This is a modeling choice, not the prior induced by the original independent uniform priors on \(m\) and \(k\).
T_re = ReparameterizedParamsTransformation(
omega2_low=0.25,
omega2_high=3.0,
sigma_shift=-3,
sigma_scale=0.5,
init_cond=OscillatorState(x=1.0, v=0.0)
)
reparameterized_prob_model = ProbabilisticDynamicalSystem(
T_re, reparameterized_harmonic_oscillator
)
reparameterized_log_posterior_wrapped = partial(
reparameterized_prob_model.log_posterior,
obs_times=obs_times,
obs_positions=obs_positions,
)
initial_positions_re = jnp.array(
[
[-0.85, -0.4],
[-0.70, 0.0],
[-0.55, 0.4],
[-0.40, 0.2],
]
)
posterior_samples_nuts_xi_re, reduced_divergences = run_nuts_chains(
reparameterized_log_posterior_wrapped,
initial_positions_re,
key=jrandom.PRNGKey(20260920),
)
flat_reduced_samples = posterior_samples_nuts_xi_re.reshape((-1, T_re.n_params))
posterior_samples_nuts_re = vmap(T_re.forward)(flat_reduced_samples)
| Coordinate | R-hat | Bulk ESS | Divergences |
|---|---|---|---|
| xi-omega2 | 1.002 | 4204 | 0 |
| xi-sigma | 1.003 | 2990 | 0 |
The reduced posterior has no \(m\)–\(k\) ridge. The trace plots and diagnostics now assess the numerical exploration of this two-parameter posterior; the structural conclusion still comes from the analytic dependence of the trajectory on \(\omega^2\).
With the known nonzero initial displacement used here, an ideal continuous noise-free position trajectory determines \(\omega^2\) on the stated positive parameter range. Finite noisy observations can still make \(\omega^2\) practically difficult to estimate, and sparse sampling can alias different frequencies. The distinction between structural and practical identifiability therefore remains essential.