---
title: "Parameters and Optimization in TVB-Optim"
format:
html:
code-fold: false
toc: true
toc-depth: 3
fig-width: 8
out-width: "100%"
jupyter: python3
execute:
cache: true
---
# Why Parameters?
Parameters serve two purposes:
1. Define which values get differentiated and subsequently optimized (see [Marking Parameters for Optimization](#marking-parameters-for-optimization)).
2. Enforce constraints during optimization by applying transformations, eg a positive constraint on a parameter (see [Enforcing Constraints](#enforcing-constraints)).
This page walks through both, then ties them together in a worked [example](#a-simple-example) that finds a fixed point of the reduced Wong-Wang model.
## General Properties of Parameters
Parameters define the `__jax_array__` method, which allows us to use them in the same way as jax arrays.
```{python}
import jax
import jax.numpy as jnp
from tvboptim.types import Parameter
p = Parameter(jnp.array([1.0, 2.0, 3.0]))
print("Shape:", p.shape)
print("Numeric op:", p + 1)
print("Jax op:", jnp.sin(p))
```
It is good practice to only use parameters for optimization and collect them afterwards for postprocessing as the `__jax_array__` protocol is still experimental ([More info](https://docs.jax.dev/en/latest/jep/28661-jax-array-protocol.html)) and might lead to unexpected results.
```{python}
from tvboptim.types import collect_parameters
collect_parameters(p) # collect all arrays from the Parameters in a PyTree
```
### Two views: .value and .constrained_value
Every parameter has two distinct views, and mixing them up is a common source of confusion:
- `.value` is the raw differentiable **leaf** the optimizer updates. For the constrained types introduced in [Enforcing Constraints](#enforcing-constraints) this lives in an internal space and may look surprising (logit space, possibly outside the bounds, normalized to ones, ...).
- `.constrained_value` is the value actually **used in computation**, always satisfying the constraints. It is just a convenience name for `__jax_array__()`, i.e. the conversion described above.
Because of the `__jax_array__` protocol you rarely call it explicitly: passing the parameter directly into any jnp/np function or arithmetic already applies that conversion, so the constrained value is what the math sees. `.constrained_value` (and `collect_parameters` for a whole PyTree) is mainly for pulling the value out explicitly during postprocessing.
For a plain `Parameter` the two views coincide. The example below uses a sigmoid-bounded parameter (introduced later) to show how far they can differ: the leaf is in logit space, while the value used in the model is back in `[0, 1]`.
```{python}
from tvboptim.types import SigmoidBoundedParameter
sp = SigmoidBoundedParameter(0.641, low=0.0, high=1.0)
print(".value (unconstrained leaf):", sp.value)
print(".constrained_value (used in computation):", sp.constrained_value)
# Used directly in a jnp function: the constrained value is what the math sees
print("jnp.sqrt(sp):", jnp.sqrt(sp), "== jnp.sqrt(sp.constrained_value):", jnp.sqrt(sp.constrained_value))
```
## Marking Parameters for Optimization
After using `prepare` on an experiment (TVB-O) or network (tvboptim.experimental.network_dynamics), we have a state object: a PyTree with jax arrays at its leaves that bundles the values and inputs the simulation needs. Usually we only want to optimize a fraction of the state. We mark those values by wrapping them in the `Parameter` type:
```{python}
from tvboptim.experimental.network_dynamics import Bunch # A dictionary with attribute access
from tvboptim.types import Parameter, show_parameters
# A simple example state
simple_state = Bunch(
global_coupling = 0.1,
time_constant = 1.0,
)
# Mark global coupling for optimization
simple_state.global_coupling = Parameter(simple_state.global_coupling)
show_parameters(simple_state)
```
Inside e.g. `OptaxOptimizer` the parameter markers tell us how to partition the state into two sub PyTrees, one that gets differentiated and one that stays static:
```{python}
from tvboptim.types import partition_state, combine_state
diff_state, static_state = partition_state(simple_state)
print("Diff state:")
print(diff_state)
print("Static state:")
print(static_state)
```
Sub PyTrees can be combined back to a single PyTree using `combine_state`. This is something that the `OptaxOptimizer` does automatically.
```{python}
#| eval: false
# Schematics of the `OptaxOptimizer`
def loss_wrapper(diff_state, static_state):
state = combine_state(diff_state, static_state) # Recombine parameters with static values
return loss(state)
jax.grad(loss_wrapper)(diff_state, static_state) # grad by default acts only on the first argument
```
Because the optimizer acts on this partition, a state without `Parameter` leaves has no optimisation variables. `run` raises instead of performing ineffective updates.
## Optimizing Under a Trace
Outside JAX tracing, `run` uses a Python loop and can invoke a callback after each step. During tracing, for example inside `ParallelExecution`, a Python loop would be unrolled into the compiled graph.
`run` detects this case and uses `jax.lax.scan`. The scan representation avoids linear growth of the compiled graph with `max_steps`.
When traced, an unset `chunk_size` defaults to `max_steps` and produces one scan. An explicit smaller value produces approximately `max_steps / chunk_size` sequential scans and may increase graph size and compilation time. It does not change the update sequence.
::: {.callout-warning}
A Python callback cannot observe individual values during tracing. `run` therefore rejects callbacks in traced execution. Run the optimizer outside the trace when per-step monitoring is required.
:::
Within a `Space` sweep, `n_vmap` controls batch width while the scan controls optimisation steps. See [Axes and Spaces](axes_and_spaces.qmd) for making swept values optimisable with `wrap=`.
## Enforcing Constraints
Some values in a model can be constrained through their physiological meaning. For example, we want the global coupling to be positive. The optimizer itself does not know about these constraints, so we have to apply them explicitly.
```{python}
from tvboptim.types import BoundedParameter
p1 = Parameter(-0.0001)
p2 = BoundedParameter(0.5, low=0.0, high=1.0)
p3 = BoundedParameter(-0.0001, low=0.0, high=1.0)
# Simple loss that would increase p to infinity in minimization
def loss(p):
return -p
print("Gradient without constraint:", jax.grad(loss)(p1))
print("Gradient with fulfilled constraint:", jax.grad(loss)(p2))
print("Gradient with violated constraint:", jax.grad(loss)(p3))
```
We provide several predefined parameter types. Recall the [two views](#two-views-value-and-constrained_value): the "leaf" column is what `.value` holds, while `.constrained_value` always returns the usable value.
| Type | Constraint | `.value` (leaf) |
| --- | --- | --- |
| `Parameter` | none | the value itself |
| `BoundedParameter` | clipped to `[low, high]` on read | unclipped, may sit outside bounds |
| `SigmoidBoundedParameter` | smoothly mapped into `[low, high]` | logit space, may be negative |
| `NormalizedParameter` | none; rescales by the initial value | normalized to ones |
| `RescaledParameter` | none; rescales by a scale you choose | `value / scale` |
| `MaskedParameter` | freezes masked entries | unconstrained entries only |
| `TransformedParameter` | custom forward/inverse transform | unconstrained pre-image |
`NormalizedParameter` uses its initial value as the scale. Use `RescaledParameter` when the expected parameter change provides a more appropriate scale. This is especially relevant near zero, where initial-value scaling can require a large displacement in the transformed coordinate to cross zero.
Custom constraints can subclass `Parameter` or use `TransformedParameter` with forward and inverse transformations. The following example implements a sigmoid-bounded parameter. Unlike hard clipping, the sigmoid transformation remains differentiable outside the physical interval, although its gradient approaches zero near the bounds.
```{python}
from tvboptim.types import TransformedParameter, SigmoidBoundedParameter
def sigmoid_bounded_param(value, low=0.0, high=1.0):
def forward(x):
return low + (high - low) * jax.nn.sigmoid(x)
def inverse(x):
normalized = (x - low) / (high - low)
return jnp.log(normalized / (1 - normalized))
return TransformedParameter(value, forward, inverse)
# Check if the constraint is fulfilled
p_custom = sigmoid_bounded_param(0.5, low=0.0, high=1.0)
print("Custom TransformedParameter:", p_custom)
print("Gradient with smooth constraint:", jax.grad(loss)(p_custom))
```
Running an optimization on the simple loss confirms the constraint holds: the parameter stays below 1.
```{python}
from tvboptim.optim import OptaxOptimizer
import optax
opt = OptaxOptimizer(loss, optax.adam(0.01))
p_opt, _ = opt.run(p_custom, max_steps=1000)
print("Optimized parameter:", p_opt)
print("Smaller than 1:", p_opt < 1.0)
```
# A Simple Example
As a simple example we use parameters to find a fixed point of the reduced Wong-Wang model: parameter values where the neural activity reaches equilibrium.
## Setting up the Model
First, we load the reduced Wong-Wang model and examine its structure:
```{python}
from tvboptim.experimental.network_dynamics.dynamics import ReducedWongWang
m = ReducedWongWang()
dfun = m.dynamics
m.DEFAULT_PARAMS
```
The model includes parameters like `gamma` (kinetic parameter) and `tau_s` (time constant) that control the dynamics.
## Defining the Optimization Problem
For a specific initial condition and coupling, we have a fixed point if the derivative is zero. We define our loss function to minimize the absolute sum of derivatives:
```{python}
p_init = m.DEFAULT_PARAMS
def loss_fixed_point(p, S_0 = [0.8], coupling = Bunch(instant = [0.0], delayed = [0.0])):
return jnp.abs(jnp.sum(dfun(0.0, S_0, p, coupling, coupling)[0]))
print(f"Initial loss: {loss_fixed_point(p_init):.6f}")
```
Without parameter constraints, JAX would differentiate all parameters in the model:
```{python}
gradients = jax.grad(loss_fixed_point)(p_init)
print("Gradients for all parameters:", gradients)
```
## Running the Optimization
We run the optimization with both regular and normalized parameters and compare their performance.
### Setting up the Optimizer
We configure the optimizer with callbacks to track progress and automatically stop when convergence is achieved:
```{python}
from tvboptim.optim import OptaxOptimizer, DefaultPrintCallback, StopLossCallback, SavingLossCallback, SavingParametersCallback, MultiCallback
import optax
# Configure callbacks for monitoring optimization progress
cbs = MultiCallback([
DefaultPrintCallback(every=10), # Print progress every 10 steps
SavingLossCallback(), # Save loss history
SavingParametersCallback(), # Save parameter history
StopLossCallback(stop_loss=1e-6), # Stop when loss is sufficiently small
])
opt = OptaxOptimizer(loss_fixed_point, optax.adam(0.01), callback=cbs)
```
### Optimization with Regular Parameters
First, optimize `gamma` and `tau_s` as regular `Parameter` objects:
```{python}
# Mark specific parameters for optimization
p_init.gamma = Parameter(p_init.gamma)
p_init.tau_s = Parameter(p_init.tau_s)
print("Starting optimization with regular Parameters...")
p_opt, fitting_data = opt.run(p_init, max_steps=1000)
print(f"Final loss: {fitting_data['loss'].save.values[-1]:.8f}")
```
### Optimization with Normalized Parameters
For comparison, `NormalizedParameter` can converge better by normalizing parameter scales (gamma = 0.641 -> 1.0, tau_s = 100 -> 1.0):
```{python}
from tvboptim.types import NormalizedParameter
# Reset and use normalized parameters
p_init.gamma = NormalizedParameter(p_init.gamma)
p_init.tau_s = NormalizedParameter(p_init.tau_s)
print("Starting optimization with NormalizedParameters...")
p_opt_norm, fitting_data_norm = opt.run(p_init, max_steps=1000)
print(f"Final loss: {fitting_data_norm['loss'].save.values[-1]:.8f}")
```
## Comparing Optimization Performance
The normalized parameters converge faster than regular parameters. With regular `Parameter` objects alone, most of the optimization change was applied to `gamma` while `tau_s` barely changed due to scaling differences and a learning rate of 0.01.
### Visualization of Convergence
The plots below show the optimization convergence and the parameter trajectories:
```{python}
#| code-fold: true
#| code-summary: "Show visualization code"
import matplotlib.pyplot as plt
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(8.1, 5.0625))
# Loss convergence comparison
ax1.semilogy(fitting_data["loss"].save, label='Regular Parameters', linewidth=2)
ax1.semilogy(fitting_data_norm["loss"].save, label='Normalized Parameters', linewidth=2, linestyle='--')
ax1.set_xlabel('Optimization Steps')
ax1.set_ylabel('Loss (log scale)')
ax1.set_title('Loss Convergence Comparison')
ax1.legend()
ax1.grid(True, alpha=0.3)
# Parameter trajectory in 2D space
gamma_traj = collect_parameters([p.gamma for p in fitting_data["parameters"].save])
tau_s_traj = collect_parameters([p.tau_s for p in fitting_data["parameters"].save])
gamma_traj_norm = collect_parameters([p.gamma for p in fitting_data_norm["parameters"].save])
tau_s_traj_norm = collect_parameters([p.tau_s for p in fitting_data_norm["parameters"].save])
ax2.plot(tau_s_traj, gamma_traj, label='Regular Parameters', linewidth=2)
ax2.plot(tau_s_traj_norm, gamma_traj_norm, label='Normalized Parameters', linewidth=2)
ax2.set_xlabel('tau_s (Time Constant)')
ax2.set_ylabel('gamma (Kinetic Parameter)')
ax2.set_title('Parameter Trajectory')
ax2.legend()
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
```
### Validating the Fixed Point Solution
Finally, run simulations with the optimized parameters to verify they reach the target fixed point at 0.8:
```{python}
# Run simulation with default parameters
m = ReducedWongWang()
ts_default = m.simulate(t1 = 1000, dt = 1.0)
# Run simulation with optimized parameters (regular Parameter)
p_opt = collect_parameters(p_opt)
m = ReducedWongWang(gamma = p_opt.gamma, tau_s = p_opt.tau_s)
ts_opt = m.simulate(t1 = 1000, dt = 1.0)
# Run simulation with optimized normalized parameters
p_opt_norm = collect_parameters(p_opt_norm)
m = ReducedWongWang(gamma = p_opt_norm.gamma, tau_s = p_opt_norm.tau_s)
ts_opt_norm = m.simulate(t1 = 1000, dt = 1.0)
```
The plot below shows how well each optimization approach reached the target fixed point:
```{python}
#| code-fold: true
#| code-summary: "Show visualization code"
fig, ax = plt.subplots(figsize=(8.1, 6.075))
# Plot target line
ax.hlines(0.8, 0, 1000, color="black", linewidth=2.5, label="Target Fixed Point (0.8)")
# Plot time series
ax.plot(ts_default[1][:, 0, :], label="Default Parameters", alpha=0.8)
ax.plot(ts_opt[1][:, 0, :], color='red', label="Regular Parameters", alpha=0.8)
ax.plot(ts_opt_norm[1][:, 0, :], color='green', linestyle='--',
label="Normalized Parameters", linewidth=2)
ax.set_xlabel('Time Steps')
ax.set_ylabel('Neural Activity (S)')
ax.set_title('Neural Activity Trajectories: Comparing Parameter Optimization Methods')
ax.legend(loc="right")
ax.grid(True, alpha=0.3)
ax.set_xlim(0, 1000)
plt.tight_layout()
plt.show()
```
Both optimized parameter sets settle at the target 0.8, while the default parameters equilibrate elsewhere.