Graph
phasic.Graph(arg, ipv=None, graph_cache=None, **kwargs)Attributes
| Name | Description |
|---|---|
| cache_trace | Whether this graph caches the elimination trace. |
| dyn_ordering | Whether dynamic minimum-degree elimination ordering is enabled. |
| hierarchical | Deprecated: use cache_trace instead. |
| trace_valid | Whether the cached trace is valid (not dirty). |
| weight_callback | Custom callback for computing edge weights from theta and coefficients. |
| weight_mode | Weight computation mode for parameterized edges. |
Methods
| Name | Description |
|---|---|
| absorbing_state_rewards | Get a reward vector that is 1 for states with edges to absorbing states. |
| accumulated_occupancy | Compute expected occupancy (visits or time) for each state. |
| accumulated_visiting_time | Compute expected time spent in each state (continuous only). |
| accumulated_visits | Compute expected number of visits to each state (discrete only). |
| add_epoch | Add an epoch boundary, returning a new graph with epoch transition edges. |
| as_matrices | Convert the graph to its matrix representation. |
| backward_probabilities | Compute P(reach target | start at v) for each vertex v. |
| cdf | Compute cumulative distribution function. |
| clear_from_cache | Delete this graph’s entries from the on-disk caches. |
| clone | Create a deep copy of this graph. |
| compute_trace | Compute elimination trace with optional hierarchical caching. |
| copy | Returns a deep copy of the graph. |
| covariance | Compute covariance matrix for multivariate phase-type distributions. |
| daisy_chain_joint_probs | JAX-traceable model: joint-probs at the t-states after a daisy chain. |
| discretize | Create a discretized copy of this graph. |
| distribution_context | Create a distribution context for efficient repeated sampling. |
| eliminate_to_dag | Perform symbolic graph elimination to create a reusable DAG structure. |
| epoch_context | Build a single-epoch context for reading cumulative t-state probabilities. |
| expectation | Compute expected value (first moment) of the phase-type distribution. |
| expected_sojourn_time | Compute expected sojourn time (residence time) in each state. |
| expected_waiting_time | Compute expected waiting time until absorption. |
| extend | Extend the graph by continuing to visit unvisited vertices using a callback. |
| find_or_create_vertex | Find or create a vertex with the given state. |
| from_matrices | Construct a Graph from matrix representation. |
| from_serialized | Reconstruct Graph from serialize() output. |
| joint_prob_graph | Build the joint-probability graph from this parameterized graph. |
| joint_stop_prob_graph | Build the joint stop-probability graph for daisy-chained inference. |
| laplace_transform | Create a Laplace-transformed graph. |
| mcmc | Run MCMC Metropolis-Hastings inference for Bayesian parameter estimation. |
| method_of_moments | Find parameter estimates by matching model moments to sample moments. |
| moments | Compute k-th moment of the phase-type distribution. |
| moments_from_graph | Convert a parameterized Graph to a JAX-compatible function that computes moments. |
| normalize | Normalize edge weights to make the graph a proper probability distribution. |
| Compute probability density/mass function using forward algorithm. | |
| plot | Plot a graph using graphviz. |
| plot_scc_decomp | Visualise the SCC decomposition of this graph as a |
| pmf_and_moments_from_graph | Convert a parameterized Graph to a function that computes both PMF/PDF and moments. |
| pmf_and_moments_from_graph_multivariate | Create a multivariate phase-type model that handles 2D observations and rewards. |
| pmf_from_cpp | Load a phase-type model from a user’s C++ file and return a JAX-compatible function. |
| pmf_from_graph | Convert a Python-built Graph to a JAX-compatible function with full gradient support. |
| pmf_from_graph_joint_index | Create a JAX-compatible model for joint index distributions. |
| pmf_from_graph_parameterized | Convert a parameterized Python graph builder to a JAX-compatible function. |
| prewarm_cache | Populate the on-disk Stage A2 cache for this graph. If parameterized, |
| probability_matching | Find parameter estimates by matching model probabilities to empirical probabilities. |
| profile | Profile this graph and recommend parallel_elimination, |
| pull_cache | Download the published compute artifact for this graph if available. |
| push_cache | Publish this graph’s compute artifact to the phasic-traces registry. |
| reward_transform | Apply reward transformation to create a new graph with modified rewards. |
| reward_transform_discrete | Apply reward transformation for discrete-time distributions. |
| reward_visit_probability | Probability of visiting any rewarded vertex before absorption. |
| sample | Generate random samples from the phase-type distribution. |
| sample_path | Sample complete path(s) through the Markov chain. |
| sample_path_conditioned | Sample path(s) conditioned on reaching a target terminal state. |
| serialize | Serialize graph to array representation for efficient computation. |
| state_probability | Compute probability of being in each state at a given time. |
| stop_probability | Compute probability of being in each state at a given time. |
| svgd | Run Stein Variational Gradient Descent (SVGD) inference for Bayesian parameter estimation. |
| update_ipv | Set the initial probability vector after construction. |
| update_weights | Update parameterized edge weights with given parameters. |
| variance | Compute variance of the phase-type distribution. |
absorbing_state_rewards
phasic.Graph.absorbing_state_rewards()Get a reward vector that is 1 for states with edges to absorbing states.
This reward vector is used for computing the Laplace transform value from a Laplace-transformed graph.
Returns
:np.ndarray-
Reward vector of length n_vertices. Element i is 1.0 if vertex i has an edge to an absorbing state, 0.0 otherwise.
Examples
>>> graph = Graph(coalescent_callback)
>>> rewards = graph.absorbing_state_rewards()
>>> laplace_graph = graph.laplace_transform(0.5)
>>> L_0_5 = laplace_graph.expectation(rewards=rewards)See Also
laplace_transform : Create a Laplace-transformed graph
accumulated_occupancy
phasic.Graph.accumulated_occupancy(*args, **kwargs)Compute expected occupancy (visits or time) for each state.
Parameters
*args :tuple= ()-
Additional positional arguments passed to C++ implementation.
****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation.
Returns
:np.ndarray-
Expected visits (discrete) or time (continuous) in each state.
Notes
This is a convenience method that dispatches to either: - accumulated_visits() for discrete distributions - accumulated_visiting_time() for continuous distributions
accumulated_visiting_time
phasic.Graph.accumulated_visiting_time(*args, **kwargs)Compute expected time spent in each state (continuous only).
Parameters
*args :tuple= ()-
Additional positional arguments passed to C++ implementation.
****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation.
Returns
:np.ndarray-
Expected time spent in each state until absorption.
Notes
Only available for continuous-time phase-type distributions. For discrete distributions, use accumulated_visits() instead.
accumulated_visits
phasic.Graph.accumulated_visits(*args, **kwargs)Compute expected number of visits to each state (discrete only).
Parameters
*args :tuple= ()-
Additional positional arguments passed to C++ implementation.
****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation.
Returns
:np.ndarray-
Expected number of visits to each state until absorption.
Raises
:ValueError-
If graph is not discrete.
Notes
Only available for discrete-time phase-type (DPH) distributions. The graph must be discretized via discretize() before calling this method.
add_epoch
phasic.Graph.add_epoch(time, callback=None, **kwargs)Add an epoch boundary, returning a new graph with epoch transition edges.
Computes transition rates from stop_probability(time) / accumulated_occupancy(time) and wires up sister vertices in the next epoch. The first call also adds an epoch index to the state vector.
Coefficient layout
After at least one add_epoch call, the per-edge coefficient vector follows a uniform interleaved layout. With base coefficient count B (i.e. the length of each coefficient list returned by the original callback), epoch k owns slots [k*(B+1) .. (k+1)*(B+1) - 1]: the first B are that epoch’s dynamics coefficients, and the last is the transition rate from epoch k-1 into epoch k. Epoch 0’s transition slot is a dummy — its coefficient is zero on every edge, so any value supplied at that index has no effect.
For example, with B = 1 and N add_epoch calls (i.e. N + 1 epochs) the coefficient vector accepted by update_weights is::
[r_0, t_0_dummy, r_1, t_{0->1}, r_2, t_{1->2}, ..., r_N, t_{(N-1)->N}]
which lets a list of per-epoch rates and times be passed directly via zip::
graph.update_weights([v for pair in zip(rates, times) for v in pair])
param_length() grows from B (no epochs) to 2*(B+1) after the first add_epoch and by B + 1 per subsequent call.
Parameters
time :float-
Time at which the epoch boundary occurs.
callback :callable= None-
Callback for building out new-epoch vertices. If None, uses the stored callback from graph construction.
****kwargs** :Any= {}-
Additional keyword arguments merged with stored callback kwargs.
Returns
:Graph-
New graph with epoch transitions wired up.
as_matrices
phasic.Graph.as_matrices()Convert the graph to its matrix representation.
Returns a NamedTuple containing the traditional phase-type distribution matrices and associated information.
Returns
:MatrixRepresentation-
NamedTuple with the following attributes: - states: np.ndarray of shape (n_states, state_dim), dtype=int32 State vector for each vertex - sim: np.ndarray of shape (n_states, n_states), dtype=float64 Sub-intensity matrix - ipv: np.ndarray of shape (n_states,), dtype=float64 Initial probability vector - indices: np.ndarray of shape (n_states,), dtype=int32 1-based indices for vertices (for use with vertex_at())
Examples
>>> g = Graph(1)
>>> start = g.starting_vertex()
>>> v1 = g.find_or_create_vertex([1])
>>> v2 = g.find_or_create_vertex([2])
>>> start.add_edge(v1, 1.0)
>>> v1.add_edge(v2, 2.0)
>>> g.normalize()
>>>
>>> matrices = g.as_matrices()
>>> print(matrices.sim) # Sub-intensity matrix (attribute access)
>>> print(matrices.ipv) # Initial probability vector
>>> # Can also use index access like a tuple
>>> states, sim, ipv, indices = matricesbackward_probabilities
phasic.Graph.backward_probabilities(target_vertices)Compute P(reach target | start at v) for each vertex v.
For each vertex, computes the probability of eventually reaching one of the target terminal states. Uses backward induction over the graph structure.
Parameters
target_vertices : list of int-
Indices of target terminal vertices.
Returns
:np.ndarray-
Array of length vertices_length() with backward probability for each vertex. Values are between 0 and 1.
cdf
phasic.Graph.cdf(time, **kwargs)Compute cumulative distribution function.
Parameters
time :floatorArrayLike-
Time point(s) at which to evaluate the CDF.
****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation.
Returns
:floatornp.ndarray-
CDF value(s) P(T ≤ t) at the specified time point(s).
Notes
For continuous distributions: F(t) = P(T ≤ t) = 1 - α · exp(S·t) · 1 For discrete distributions: F(n) = P(N ≤ n) = sum of PMF up to n
clear_from_cache
phasic.Graph.clear_from_cache(
graph_cache=True,
parameterized_reward_compute=True,
)Delete this graph’s entries from the on-disk caches.
Parameters
graph_cache :bool= True-
If True, remove the serialised Graph entry from
~/.phasic_cache/graphs/<callback_hash>.json. Requires that this graph was built from a callback (so the callback + construction kwargs are available to recompute the same hash the constructor used). Manually constructed graphs have no callback-hash key and will raise. parameterized_reward_compute :bool= True-
If True, remove this graph’s Stage A2 symbolic elimination entry from
~/.phasic_cache/parameterized_reward_compute/<content_hash>.bin. Per-SCC entries (scc_<synth_hash>.bin) are not touched because they are content-addressed by SCC subgraph topology and may be shared with other graphs that have the same substructure.
Returns
:dict[str,int]-
{'graph_cache': n, 'parameterized_reward_compute': m}where each value is the number of files actually removed (0 if the entry was not present, or if the flag was False).
Raises
:ValueError-
If neither flag is True.
:RuntimeError-
If
graph_cache=Truewas requested but no callback is stored on this instance (e.g. graph built viaGraph(state_length)then populated manually).
Notes
Missing files are not an error — they just mean the graph was never cached (or has already been cleared). The only conditions that raise are user errors (no flags, no callback key available).
For bulk operations across many graphs prefer :func:phasic.cache.clear_param_compute_cache and :func:phasic.clear_all_graph_caches.
Examples
>>> g = Graph(model, indexer=indexer, theta_dim=1)
>>> g.update_weights([1.0])
>>> _ = g.expectation() # populates parameterized_reward_compute
>>> g.clear_from_cache(graph_cache=False) # keep graph cache
{'graph_cache': 0, 'parameterized_reward_compute': 1}clone
phasic.Graph.clone()Create a deep copy of this graph.
The cloned graph preserves the cache_trace setting but starts with a fresh (invalid) trace cache.
Returns
:Graph-
A new Graph instance with the same structure.
compute_trace
phasic.Graph.compute_trace(
param_length=None,
hierarchical=True,
min_size=50,
parallel='auto',
verbose=False,
force=False,
)Compute elimination trace with optional hierarchical caching.
.. deprecated:: The Python EliminationTrace machinery is no longer wired to the public moments() / expectation() / variance() entry points (those route directly to the C++ implementation, which uses the Stage A0-cached parameterized_reward_compute_graph). compute_trace is preserved for callers that still drive the trace pipeline directly, but emits a DeprecationWarning and will be removed in a future release.
When Graph was created with cache_trace=True, the trace is cached on the instance for use by moments(), expectation(), etc. In this mode, the operation is NON-DESTRUCTIVE (graph is preserved via cloning).
When Graph was created with cache_trace=False (default), the operation is DESTRUCTIVE and will empty the graph during trace recording.
Parameters
param_length :int= None-
Number of parameters (auto-detect if None)
hierarchical :bool= True-
If True, use hierarchical SCC-based caching (recommended). If False, use direct trace recording without caching. Caching provides 10-100x speedup on repeated calls.
min_size :int= 50-
Minimum vertices to subdivide (only used if hierarchical=True)
parallel :str= 'auto'-
Parallelization: ‘auto’, ‘vmap’, ‘pmap’, or ‘sequential’
verbose :bool= False-
If True, show progress bars for major computation stages
force :bool= False-
If True, recompute trace even if a cached trace exists and is valid. Only applicable when Graph was created with cache_trace=True.
Returns
:EliminationTrace-
Elimination trace (from cache or computed)
Notes
Caching Mode (Graph created with cache_trace=True): - Trace is cached on the instance and reused by moments(), expectation(), etc. - Graph is cloned before trace recording, preserving the original - Subsequent calls return cached trace unless force=True or graph was modified
Non-Caching Mode (default): - The graph elimination algorithm is DESTRUCTIVE - vertices are eliminated - Use disk caching (hierarchical=True parameter) to avoid re-recording
Examples
>>> # Cache mode - non-destructive, cached on instance
>>> g = Graph(model, nr_samples=5, cache_trace=True)
>>> g.normalize()
>>> trace = g.compute_trace() # Graph preserved, trace cached
>>> g.update_weights([1.0, 2.0])
>>> mean = g.expectation() # Uses cached trace
>>>
>>> # Standard mode with disk caching
>>> g = Graph(model, nr_samples=5)
>>> trace = g.compute_trace() # Graph emptied, trace returnedcopy
phasic.Graph.copy()Returns a deep copy of the graph.
Creates an independent copy with all vertices, edges, and metadata. Modifications to the copy will not affect the original graph.
Returns
:Graph-
Deep copy of the graph
covariance
phasic.Graph.covariance(rewards1=[], rewards2=[], **kwargs)Compute covariance matrix for multivariate phase-type distributions.
Parameters
rewards1 : list of float or ndarray = []-
The first set of rewards, which should be applied to the phase-type distribution. Must have length equal to
vertices_length(). rewards2 : list of float or ndarray = []-
The second set of rewards, which should be applied to the phase-type distribution. Must have length equal to
vertices_length(). ****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation.
Returns
:np.ndarray-
Covariance matrix for the multivariate distribution.
Notes
This method is for multivariate phase-type distributions with multiple marginals. For univariate distributions, use variance().
daisy_chain_joint_probs
phasic.Graph.daisy_chain_joint_probs(
epoch_thetas,
epoch_dts,
initial_ipv,
t_eval=None,
fixed_indices=None,
granularity=0,
)JAX-traceable model: joint-probs at the t-states after a daisy chain.
Daisies through len(epoch_dts) epoch transitions (update_ipv → update_weights → stop_probability(dt)), then in the final epoch sets the propagated IPV and the final-epoch theta on the JSP graph and reads joint_stop_probabilities(t_eval) at the t-state vertex positions. The number of epochs is len(epoch_thetas), which must equal len(epoch_dts) + 1: each epoch i in 0..n_epochs-2 has a transition of duration epoch_dts[i]; the final epoch n_epochs-1 has no transition out.
The joint probability of an outcome is the relative probability of paths ending in (and being trapped in) that t-state. Because the JSP graph’s t-aux loops trap mass, stop_probability(t) at a t-vertex monotonically approaches the joint probability as t → ∞. t_eval should be large enough that the chain has had time to absorb most mass at the t-states. For slow-mutation models t_eval may need to be quite large; the default scales with sum(epoch_dts) to provide a conservative starting point. For an adaptive t_eval chosen via a residual-mass probe, use Graph.svgd(..., daisy_chain_t_eval='auto') or call Graph._probe_daisy_t_eval(...) directly.
Parameters
epoch_thetas : (array-like,shape(n_epochs,theta_dim))-
Per-epoch parameter vectors. JAX-traced.
epoch_dts : (array-like,shape(n_epochs- 1))-
Durations of the first
n_epochs - 1epochs. Static Python sequence (not JAX-traced). initial_ipv : (array-like,shape(n_ipv))-
IPV for epoch 0, in the JSP graph’s IPV layout.
t_eval :float= None-
Time at which the final-epoch joint stop-probabilities are read. Defaults to
max(sum(epoch_dts) * 4, 10.0). fixed_indices : sequence of int = None-
Flat-theta indices held fixed by SVGD. Forwarded to the
custom_vjpbackward pass to skip finite-difference gradient evaluations on those slots. granularity :int= 0-
Uniformization granularity passed to the underlying
stop_probabilitycall in the C++ FFI handler.0(default) auto-picks a safe value from the graph’s max rate and the requested time. Larger values give finer discretisation (slower, more accurate); smaller positive values trade accuracy for speed. The same granularity is used for every epoch’sstop_probabilitycall.
Returns
: (jax.Array,shape(n_t_vertices))-
Joint-probs at the t-states, in the order of
self._t_vertex_indices.
discretize
phasic.Graph.discretize(rate, skip_existing=False, **kwargs)Create a discretized copy of this graph.
Returns a new graph with auxiliary vertices added for each transient state. The original graph is not modified.
Parameters
rate :floatorcallable-
Discretization rate. If callable, receives state array and **kwargs, must return a scalar rate (for non-parameterized graphs) or a coefficient vector (for parameterized graphs).
skip_existing :bool= False-
If True, skip vertices that already have auxiliary vertices.
Returns
:Graph-
New discretized graph with
.rewardsattribute containing the reward vector (1 for auxiliary vertices, 0 otherwise).
distribution_context
phasic.Graph.distribution_context(*args, **kwargs)Create a distribution context for efficient repeated sampling.
Parameters
*args :tuple= ()-
Additional positional arguments passed to C++ implementation.
****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation.
Returns
:object-
Distribution context object that can be used for efficient sampling.
Notes
The distribution context precomputes data structures needed for sampling, making repeated sample() calls much faster than sampling directly from the graph.
eliminate_to_dag
phasic.Graph.eliminate_to_dag()Perform symbolic graph elimination to create a reusable DAG structure.
This method performs the O(n³) graph elimination algorithm ONCE and returns a symbolic DAG where edges contain expression trees instead of concrete values. The DAG can then be instantiated with different parameters in O(n) time each.
This is the key optimization for SVGD and other inference methods that require evaluating the same graph structure with many different parameter vectors.
Returns
:SymbolicDAG-
Symbolic DAG that can be instantiated with parameters
Raises
:RuntimeError-
If the graph is not parameterized or elimination fails
Examples
>>> # Create parameterized graph
>>> g = Graph(1)
>>> v_a = g.create_vertex([0])
>>> v_b = g.create_vertex([1])
>>> v_c = g.create_vertex([2])
>>> v_a.add_edge_parameterized(v_b, 0.0, [1.0, 0.0, 0.0])
>>> v_b.add_edge_parameterized(v_c, 0.0, [0.0, 1.0, 0.0])>>> # Eliminate to symbolic DAG (once)
>>> dag = g.eliminate_to_dag()
>>> print(dag) # SymbolicDAG(vertices=3, params=3, acyclic=True)>>> # Fast instantiation for SVGD (100-1000× faster!)
>>> for theta in particle_swarm:
... g_concrete = dag.instantiate(theta)
... log_prob = -g_concrete.expectation() # Fast!Performance
- Elimination: O(n³) - performed once
- Instantiation: O(n) - performed per particle
- Expected speedup for SVGD: 100-1000×
See Also
SymbolicDAG : The returned symbolic DAG class SymbolicDAG.instantiate : Create concrete graph from parameters
epoch_context
phasic.Graph.epoch_context()Build a single-epoch context for reading cumulative t-state probabilities.
Allowed only on continuous joint-prob graphs (built via joint_prob_graph(..., discrete=False)). Builds the joint-stop-probability (JSP) graph internally, projects this graph’s starting-vertex IPV into the JSP graph’s IPV layout, inherits the current theta (whatever was last passed to this graph’s update_weights), and returns an :class:EpochContext ready for cumulative_probs calls.
If you have not called update_weights on this graph yet, you must call ctx.update_weights(theta) on the returned context before reading probabilities.
For the multi-epoch / daisy-chain reads used by SVGD, see :meth:daisy_chain_joint_probs.
Returns
:EpochContext-
A context object whose
cumulative_probs(t)returnsP(absorbed via t-state by time t)for each t-state. Converges to :meth:joint_prob_table(built with the same settings anddiscrete=True) ast → ∞.
Raises
:ValueError-
If this graph is not a joint-prob graph, or is the discrete variant (use :meth:
joint_prob_tablefor asymptotes directly).
expectation
phasic.Graph.expectation(rewards=[], **kwargs)Compute expected value (first moment) of the phase-type distribution.
Parameters
rewards :ArrayLike= []-
Reward vector for reward-transformed expectation. If not provided, computes E[T] where T is time until absorption.
****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation.
Returns
:float-
Expected value E[T] or reward-transformed expectation.
Notes
For parameterized graphs, this method uses trace-based computation which requires O(n) memory instead of O(n²) for the matrix-based approach. Set cache_trace=True when creating the graph to cache the trace for faster repeated evaluations.
This is equivalent to moments(1, rewards).
expected_sojourn_time
phasic.Graph.expected_sojourn_time(*args, **kwargs)Compute expected sojourn time (residence time) in each state.
Parameters
*args :tuple= ()-
Positional arguments passed to C++ implementation.
****kwargs** :dict= {}-
Keyword arguments passed to C++ implementation.
Returns
:ArrayLike-
Expected sojourn time for each vertex.
Notes
This method wraps the C++ implementation of expected_sojourn_time. Returns the expected accumulated reward for the starting vertex propagated through all paths. This is what expectation() uses internally. The difference from expected_residence_time is subtle - expected_residence_time decomposes this into per-vertex contributions.
Available for both continuous and discrete phase-type distributions.
expected_waiting_time
phasic.Graph.expected_waiting_time(*args, **kwargs)Compute expected waiting time until absorption.
Parameters
*args :tuple= ()-
Positional arguments passed to C++ implementation.
****kwargs** :dict= {}-
Keyword arguments passed to C++ implementation.
Returns
:float-
Expected waiting time until absorption.
Notes
This method wraps the C++ implementation of expected_waiting_time. Only available for continuous-time phase-type distributions.
extend
phasic.Graph.extend(callback=None, vertex_index=None, **kwargs)Extend the graph by continuing to visit unvisited vertices using a callback.
After manually adding vertices to the graph (e.g., via find_or_create_vertex), this method continues the callback-based graph building process, visiting all newly added unvisited vertices.
Parameters
callback :callable= None-
Callback function to use for extending the graph. If None, uses the callback from the original Graph construction. The callback should accept a state array and return: - For non-parameterized: list of (state, weight) tuples - For parameterized: list of (state, [coefficients]) tuples
****kwargs** :Any= {}-
Additional keyword arguments to pass to the callback function. If None, uses the kwargs from the original Graph construction.
Raises
:RuntimeError-
If no callback is provided and the graph was not constructed with a callback.
Examples
>>> # Build initial graph
>>> graph = Graph(my_callback, nr_samples=5)
>>>
>>> # Manually add new vertex
>>> special_vertex = graph.find_or_create_vertex([100, 200])
>>> graph.starting_vertex().add_edge(special_vertex, [1.5])
>>>
>>> # Continue with callback to explore new vertices
>>> graph.extend() # Uses original callback
>>>
>>> # Or use different callback
>>> graph.extend(callback=my_other_callback, param=value)find_or_create_vertex
phasic.Graph.find_or_create_vertex(state)Find or create a vertex with the given state.
This method wraps the C++ implementation to track trace invalidation.
from_matrices
phasic.Graph.from_matrices(ipv, sim, states=None)Construct a Graph from matrix representation.
Parameters
ipv :np.ndarray-
Initial probability vector, shape (n_states,)
sim :np.ndarray-
Sub-intensity matrix, shape (n_states, n_states)
states :np.ndarray= None-
State vectors, shape (n_states, state_dim), dtype=int32 If None, uses default states [0], [1], [2], …
Returns
:Graph-
The reconstructed phase-type distribution graph
Examples
>>> ipv = np.array([0.6, 0.4])
>>> sim = np.array([[-2.0, 1.0], [0.0, -3.0]])
>>> g = Graph.from_matrices(ipv, sim)
>>> pdf = g.pdf(1.0)from_serialized
phasic.Graph.from_serialized(data)Reconstruct Graph from serialize() output.
This method enables distributed trace recording by allowing graphs to be serialized to JSON, sent across the network via JAX pmap, and reconstructed on worker processes.
Parameters
data :dict[str,Any]-
Dictionary returned by Graph.serialize() containing: - states: np.ndarray of shape (n_vertices, state_length) - edges: np.ndarray of shape (n_edges, 3) with [from_idx, to_idx, weight] - start_edges: np.ndarray of shape (n_start_edges, 2) with [to_idx, weight] - param_edges: np.ndarray of shape (n_param_edges, 2+param_length) Format (v0.22.0+): [from_idx, to_idx, coeff1, coeff2, …] - start_param_edges: np.ndarray of shape (n_start_param_edges, 1+param_length) Format (v0.22.0+): [to_idx, coeff1, coeff2, …] - param_length: int - state_length: int - n_vertices: int
Returns
:Graph-
Reconstructed graph with identical structure to the original
Raises
:ValueError-
If data is missing required fields, has wrong shapes, or malformed arrays
:RuntimeError-
If graph reconstruction fails (e.g., edges to non-existent vertices)
Examples
>>> import json
>>> import numpy as np
>>> from phasic import Graph
>>>
>>> # Create and serialize graph
>>> g = Graph(1)
>>> v0 = g.starting_vertex()
>>> v1 = g.find_or_create_vertex([1])
>>> v0.add_edge_parameterized(v1, 0.0, [2.0])
>>> serialized = g.serialize(theta_dim=1)
>>>
>>> # Convert to JSON (for network transmission)
>>> json_dict = {k: v.tolist() if isinstance(v, np.ndarray) else v
... for k, v in serialized.items()}
>>> json_str = json.dumps(json_dict)
>>>
>>> # Reconstruct on worker (e.g., different machine)
>>> received_dict = json.loads(json_str)
>>> received_dict['states'] = np.array(received_dict['states'], dtype=np.int32)
>>> received_dict['edges'] = np.array(received_dict['edges'], dtype=np.float64)
>>> received_dict['start_edges'] = np.array(received_dict['start_edges'], dtype=np.float64)
>>> received_dict['param_edges'] = np.array(received_dict['param_edges'], dtype=np.float64)
>>> received_dict['start_param_edges'] = np.array(received_dict['start_param_edges'], dtype=np.float64)
>>> g_reconstructed = Graph.from_serialized(received_dict)
>>> assert g_reconstructed.vertices_length() == g.vertices_length()joint_prob_graph
phasic.Graph.joint_prob_graph(
base_graph_indexer=None,
reward_only=None,
reward_rates_callback=None,
mutation_rate=1.0,
reward_limit=None,
tot_reward_limit=np.inf,
discrete=True,
)Build the joint-probability graph from this parameterized graph.
Optimised reimplementation of :meth:_joint_prob_graph that produces a bit-identical graph (same vertices, same insertion order, same edges/weights, same serialization) while building much faster.
The speedup comes from hoisting the per-base-state-index → reward-index mapping out of the per-vertex construction loop: that mapping is a pure function of the indexers and was previously recomputed (via millions of index_to_props / props_to_index calls) once per joint vertex.
When a custom reward_rates_callback is supplied the fast path is bypassed and the callback is invoked exactly as in :meth:_joint_prob_graph, so behaviour is unchanged for custom callbacks.
joint_stop_prob_graph
phasic.Graph.joint_stop_prob_graph()Build the joint stop-probability graph for daisy-chained inference.
Promotes the notebook helper (in docs/pages/tutorial/time_inhom_joint_prob.ipynb) into the library. The transformation: for each “t-vertex” in the source joint-prob graph (a vertex that has a transition to an absorbing vertex), wire a “trapping aux loop” — an aux vertex with state [0,...,0] and bidirectional unit-weight parameterised edges to/from the t-vertex. Mass that reaches a t-vertex shuttles between t-vertex and aux forever, so stop_probability(t)[t_vertex] + stop_probability(t)[aux] equals the cumulative joint absorption mass at that t-state by time t.
Initial-probability-vector edges (starting vertex outgoing) are added at construction time to every non-aux, non-trash, non- absorbing vertex with weight 0. The user calls update_ipv before each epoch to set them; the daisy-chain loop calls it between epochs to propagate surviving mass forward. The IPV vector layout matches joint_stop_probabilities output (vertex order, skipping aux vertices and the trash pair, expanded back to full vertex space — see _collapse_t_aux).
Returns
:Graph-
New graph with attributes
_joint_stop_prob_graph = True,_t_vertex_indices(sorted list of new-graph t-vertex indices),_t_aux_map(dict mapping new-graph t-vertex index → new-graph aux index), and the propagated_joint_prob_base_graph_indexer/_rewarded_props/_cache_tracefrom the source.
Raises
:ValueError-
If
selfis not a joint-prob graph (no_joint_prob_base_graph_indexer) or has no parameterised edges (param_length() == 0).
laplace_transform
phasic.Graph.laplace_transform(theta)Create a Laplace-transformed graph.
Returns a new graph where each transient state has an additional edge to the absorbing state with weight theta (or theta added to existing absorbing edge weight).
To compute the Laplace transform value L(theta) = E[exp(-theta * T)], call expectation() on the result with a reward vector that is 1 for states that had edges to absorbing in the original graph and 0 otherwise.
Parameters
theta :float-
The Laplace transform parameter.
Returns
:Graph-
A new graph representing the Laplace-transformed distribution.
Examples
>>> import numpy as np
>>> graph = Graph(coalescent_callback)
>>> # Get reward vector: 1 for states with absorbing edges, 0 otherwise
>>> rewards = graph.absorbing_state_rewards()
>>> laplace_graph = graph.laplace_transform(0.5)
>>> laplace_value = laplace_graph.expectation(rewards=rewards)Notes
The Laplace transform of a phase-type distribution PH(alpha, S) is:
L(theta) = E[exp(-theta * T)] = alpha * (theta*I - S)^(-1) * s
where s is the exit rate vector indicating states with direct transitions to absorption.
For continuous distributions only. For discrete distributions, use the z-transform instead.
See Also
absorbing_state_rewards : Get the reward vector for Laplace transform computation
mcmc
phasic.Graph.mcmc(
observed_data,
discrete=None,
prior=None,
n_samples=10000,
n_chains=4,
burn_in=1000,
thin=1,
proposal_scale=None,
theta_init=None,
theta_dim=None,
seed=None,
verbose=False,
progress=True,
jit=None,
positive_params=True,
param_transform=None,
rewards=None,
fixed=None,
)Run MCMC Metropolis-Hastings inference for Bayesian parameter estimation.
Samples from the posterior distribution p(theta | data) using random-walk Metropolis-Hastings with multiple independent chains.
Parameters
observed_data :arrayorSparseObservations-
Observed data. A plain 1-D array for univariate models. For multivariate models use
SparseObservations. discrete :bool= None-
If True, computes discrete PMF. If False, continuous PDF. If None, inferred from the graph.
prior : callable or list of Prior objects = None-
Log prior function. If None, constructs
DataPrior. n_samples :int= 10000-
Number of posterior samples per chain (after burn-in).
n_chains :int= 4-
Number of independent chains.
burn_in :int= 1000-
Number of initial samples to discard.
thin :int= 1-
Keep every thin-th sample.
proposal_scale :floatorarray= None-
Proposal standard deviation. If None, defaults to 0.1.
theta_init :array= None-
Initial parameter values. Shape (n_chains, theta_dim) or (theta_dim,).
theta_dim :int= None-
Dimension of parameter vector. Inferred from graph if None.
seed :int= None-
Random seed.
verbose :bool= False-
Print progress information.
progress :bool= True-
Display progress bar.
jit :boolor None = None-
JIT-compile log-probability function.
positive_params :bool= True-
Constrain parameters to positive domain via softplus.
param_transform :callable= None-
Custom parameter transformation.
rewards :array= None-
Reward vector/matrix for multivariate distributions.
fixed :listorarray= None-
Fixed parameters: [(index, value), …] or binary mask.
Returns
:MCMC-
MCMC object with results accessible via get_results() and summary().
Examples
>>> g = Graph(...) # parameterized graph
>>> data = np.random.exponential(0.5, size=200)
>>> mcmc = g.mcmc(data, n_samples=5000, n_chains=4)
>>> mcmc.summary()
>>> print(mcmc.get_results()['theta_mean'])method_of_moments
phasic.Graph.method_of_moments(
observed_data,
nr_moments=None,
rewards=None,
fixed=None,
theta_dim=None,
theta_init=None,
std_multiplier=2.0,
discrete=None,
verbose=True,
weighted=True,
)Find parameter estimates by matching model moments to sample moments.
Solves the nonlinear least-squares problem::
minimize ||moments_fn(theta) - sample_moments||^2
subject to theta > 0
The returned MoMResult.prior list can be passed directly to :meth:Graph.svgd as the prior argument, providing data-informed priors centred on the method-of-moments estimates.
Parameters
observed_data :arrayorSparseObservations-
Observed data. A plain 1-D array is accepted for univariate models (must not contain NaN). For multivariate models use
SparseObservations(seedense_to_sparse()), in which caserewardsmust also be provided. nr_moments :int= None-
Number of moments to match per feature dimension. If
None(default), automatically chosen based onweighted: whenweighted=True, adaptive selection prunes high-order moments by condition number; whenweighted=False, uses the heuristicmax(2 * n_free_params, 4). Still auto-increased if a user-specified value gives fewer equations than free parameters. rewards :np.ndarray= None-
Reward vectors.
Nonefor standard moments, 1-D for a single reward vector, 2-D(n_features, n_vertices)for multivariate. fixed :list= None-
List of
(index, value)tuples pinning specific parameters. theta_dim :int= None-
Number of model parameters. Inferred from the graph when
None. theta_init :np.ndarray= None-
Initial guess for the free parameters (excluding fixed ones). If
Nonea coordinate-wise grid search is used. std_multiplier :float= 2.0-
Factor applied to the asymptotic standard error to obtain the prior standard deviation:
prior_std = std_multiplier * se. discrete :bool= None-
Truefor discrete (PMF) models,Falsefor continuous (PDF). IfNone, inferred fromself.is_discrete. verbose :bool= True-
Print progress information.
weighted :bool= True-
If
True(default), use two-step efficient GMM with optimal weighting matrixW = Σ⁻¹. This down-weights high-variance moments and generally produces tighter standard errors. IfFalse, use unweighted least squares (legacy behavior).
Returns
:MoMResult-
Dataclass with
theta,std,prior,success, etc.
Examples
>>> g = Graph(...) # parameterized graph
>>> data = np.random.exponential(0.5, size=200)
>>> mom = g.method_of_moments(data)
>>> print(mom.theta) # parameter estimate
>>> svgd = g.svgd(data, prior=mom.prior) # use as SVGD priormoments
phasic.Graph.moments(power, rewards=[], discrete=False, **kwargs)Compute k-th moment of the phase-type distribution.
Parameters
power :int-
Moment order (1 for first moment, 2 for second moment, etc.).
rewards :ArrayLike= []-
Reward vector for reward-transformed moments. If not provided, uses unit rewards (standard moments).
discrete :bool= False-
If True, compute discrete-time moments (DPH distribution). Requires that the graph was discretized via discretize().
****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation.
Returns
:float-
The k-th moment E[T^k] where T is the time until absorption.
Notes
If the graph is parameterized, this method uses trace-based computation for 5-10x speedup on repeated evaluations and O(n) memory usage. Otherwise falls back to direct C++ graph elimination.
For higher moments (k > 2), numerical stability may become an issue for complex distributions.
moments_from_graph
phasic.Graph.moments_from_graph(graph, nr_moments=2, use_ffi=False)Convert a parameterized Graph to a JAX-compatible function that computes moments.
This method creates a function that computes the first nr_moments moments of the phase-type distribution: [E[T], E[T^2], …, E[T^nr_moments]].
Moments are computed using the existing C++ graph.moments(power) method for efficiency.
Parameters
graph :Graph-
Parameterized graph built using the Python API with parameterized edges. Must have edges created with
add_edge_parameterized(). nr_moments :int= 2-
Number of moments to compute. For example: - 1: Returns [E[T]] (mean only) - 2: Returns [E[T], E[T^2]] (mean and second moment) - 3: Returns [E[T], E[T^2], E[T^3]]
use_ffi :bool= False-
If True, uses Foreign Function Interface approach.
Returns
:callable-
JAX-compatible function with signature: moments_fn(theta) -> jnp.array(nr_moments,) Returns array of moments: [E[T], E[T^2], …, E[T^k]]
Examples
>>> # Create parameterized coalescent model
>>> def coalescent(state, nr_samples=2):
... if len(state) == 0:
... return [(np.array([nr_samples]), 1.0, [1.0])]
... if state[0] > 1:
... n = state[0]
... rate = n * (n - 1) / 2
... return [(np.array([n-1]), 0.0, [rate])]
... return []
>>>
>>> graph = Graph(coalescent, nr_samples=3)
>>> moments_fn = Graph.moments_from_graph(graph, nr_moments=2)
>>>
>>> # Compute moments for given theta
>>> theta = jnp.array([0.5])
>>> moments = moments_fn(theta) # [E[T], E[T^2]]
>>> print(f"Mean: {moments[0]}, Second moment: {moments[1]}")
>>>
>>> # Variance can be computed as: Var[T] = E[T^2] - E[T]^2
>>> variance = moments[1] - moments[0]**2Notes
- Requires graph to have parameterized edges (created with parameterized=True)
- Moments are raw moments, not central moments
- For variance, compute: Var[T] = E[T^2] - E[T]^2
- For standard deviation: std[T] = sqrt(Var[T])
normalize
phasic.Graph.normalize(*args, **kwargs)Normalize edge weights to make the graph a proper probability distribution.
Parameters
*args :tuple= ()-
Additional positional arguments passed to C++ implementation.
****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation.
Returns
:float-
Scaling factor applied to normalize the weights.
Notes
This method modifies the graph in-place and invalidates any cached trace.
For continuous distributions: Normalizes transition rates. For discrete distributions: Normalizes transition probabilities to sum to 1.
The normalization ensures that the initial probability vector and transition matrix satisfy the requirements for a valid phase-type distribution.
phasic.Graph.pdf(time, granularity=0, **kwargs)Compute probability density/mass function using forward algorithm.
Parameters
time :floatorArrayLike-
Time point(s) at which to evaluate the PDF/PMF.
granularity :int= 0-
Granularity for uniformization (default: 0, auto-detected as 2*max_rate). Higher values improve accuracy but increase computation time.
****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation.
Returns
:floatornp.ndarray-
PDF/PMF value(s) at the specified time point(s).
Notes
This method uses the forward algorithm (Algorithm 4) via uniformization to compute the exact phase-type PDF/PMF, not an approximation.
For continuous distributions: f(t) = α · exp(S·t) · s* For discrete distributions: p(n) = probability of absorption at jump n
plot
phasic.Graph.plot(
graph,
filename=None,
wrap=True,
label_fmt=None,
rate_fmt=None,
subgraphfun=None,
by_state=None,
by_index=None,
max_nodes=100,
dark=None,
constraint=True,
ranksep=1,
nodesep=1,
rankdir='LR',
size=(7, 7),
fontsize=12,
rainbow=True,
penwidth=1,
taillabel=False,
seed=1,
graph_attr={},
node_attr={},
edge_attr={},
)Plot a graph using graphviz.
Parameters
graph :Graph-
The phasic graph object to visualize.
filename :str| None = None-
If provided, save the graph to this file. The file extension determines the output format (e.g.,
'graph.pdf'). wrap :bool|int= True-
Whether to wrap vertex labels, and if so, the maximum number of characters per line. By default True.
label_fmt :Callable[…,str] | None = None-
Callable for format node labels:
rate_fmt :Callable[float] | None = None-
Callable for format edge labels:
subgraphfun :Callable[…,str] | None = None-
Deprecated. Use
by_stateinstead. Callback function defining subgraph clusters by state. by_state :Callable[…,str] | None = None-
Callback function defining subgraph clusters. Takes a state as input and returns a string used as the subgraph label.
by_index :Callable[[int],str] | None = None-
Callback function defining subgraph clusters. Takes a vertex index as input and returns a string used as the subgraph label.
max_nodes :int= 100-
Maximum number of vertices to plot, by default 100.
dark :bool| None = None-
Whether to use dark mode for the graph. Detected automatically from the VS Code theme if
vscodenbis available. constraint :bool= True-
Graphviz constraint attribute, by default True.
ranksep :float= 1-
Graphviz ranksep attribute, by default 1.
nodesep :float= 1-
Graphviz nodesep attribute, by default 1.
rankdir :str= 'LR'-
Graphviz rankdir attribute, by default
"LR". size :tuple[int,int] = (7, 7)-
Graphviz size as
(width, height), by default(7, 7). fontsize :int= 12-
Graphviz fontsize attribute, by default 12.
rainbow :bool= True-
Whether to color edges with random colors, by default True.
penwidth :float= 1-
Graphviz penwidth attribute, by default 1.
taillabel :bool= False-
Use taillabel instead of xlabel, by default False
seed :int= 1-
Random seed for graph layout, by default 1.
graph_attr :dict= {}-
graphviz graph attributes to override defaults.
node_attr :dict= {}-
graphviz node attributes to override defaults.
edge_attr :dict= {}-
graphviz edge attributes to override defaults.
Returns
:graphviz.Digraph| None-
Graphviz Digraph object for display in Jupyter notebooks, or
Noneif the graph exceedsmax_nodes.
plot_scc_decomp
phasic.Graph.plot_scc_decomp(
figsize=None,
cmap='viridis',
show_indices=True,
annotate_sizes=True,
title=False,
ax=None,
)Visualise the SCC decomposition of this graph as a level-wise treemap.
Rows correspond to the levels of the SCC condensation. The source-side (start vertex, where the chain enters) is drawn at the top of the figure; the sink-side (absorbing state) is at the bottom — time flows downward.
Within a row, each tile is one SCC, with width proportional to the SCC’s vertex count, drawn at a common absolute scale shared across all rows. So a narrow row really has fewer total vertices than a wide one. SCCs at the same level are eliminated independently when parallel_elimination=True is enabled, so wide rows signal good parallelism potential and narrow rows are elimination bottlenecks.
Level labels on the left margin look like L7 (16) — the level number followed by the count of parallel SCCs at that level. Note that the C-side composer processes levels in the opposite of the plot’s vertical order (sink-first, bottom-up), but that detail does not affect interpretation: parallelism is per-row in either direction.
This is a structural visualisation only — it does not depend on any runtime telemetry. To assess actual cache hit/miss behaviour after a compose, use phasic.cache.scc_compose_stats().
Parameters
figsize : tuple of float = None-
Matplotlib figure size in inches. Ignored if
axis provided. cmap :str= 'viridis'-
Matplotlib colormap name. Tiles are coloured by SCC index to make adjacent SCCs visually distinct.
show_indices :bool= True-
Print the SCC index inside each tile when the tile is wide enough.
annotate_sizes :bool= True-
Print the vertex count alongside the index.
title :bool= False-
Whether to show a title. Default is False. If True adds a one-line summary of the decomposition (number of SCCs, levels, widest row).
ax :matplotlib.axes.Axesor None = None-
Existing axes to draw into. If
None, a new figure is created.
Returns
:matplotlib.axes.Axes-
The axes the treemap was drawn into.
Examples
>>> import phasic
>>> g = phasic.Graph(my_callback)
>>> ax = g.plot_scc_decomp()
>>> ax.figure.savefig('scc.pdf')See Also
scc_decomposition : underlying SCC structure phasic.distributed_scc.compute_scc_levels : level grouping used by this plot
pmf_and_moments_from_graph
phasic.Graph.pmf_and_moments_from_graph(
graph,
nr_moments=2,
discrete=False,
use_ffi=False,
theta_dim=None,
)Convert a parameterized Graph to a function that computes both PMF/PDF and moments.
This is more efficient than calling pmf_from_graph() and moments_from_graph() separately because it builds the graph once and computes both quantities.
Parameters
graph :Graph-
Parameterized graph built using the Python API with parameterized edges.
nr_moments :int= 2-
Number of moments to compute
discrete :bool= False-
If True, computes discrete PMF. If False, computes continuous PDF.
use_ffi :bool= False-
If True, uses Foreign Function Interface approach.
theta_dim :int= None-
Number of parameters for parameterized edges. If not provided, will be auto-detected by probing edge states. Providing this explicitly avoids potential issues with auto-detection reading garbage memory.
Returns
:callable-
JAX-compatible function with signature: model(theta, times, rewards=None) -> (pmf_values, moments) Where: - theta: Parameter vector - times: Time points or jump counts - rewards: Optional reward vector (one per vertex). If None, computes standard moments. - pmf_values: jnp.array(len(times),) - PMF/PDF values at each time - moments: jnp.array(nr_moments,) - [E[T], E[T^2], …] or [E[R·T], E[R·T^2], …]
Examples
>>> # Create parameterized model
>>> graph = Graph(coalescent, nr_samples=3)
>>> model = Graph.pmf_and_moments_from_graph(graph, nr_moments=2)
>>>
>>> # Compute both PMF and moments
>>> theta = jnp.array([0.5])
>>> times = jnp.array([1.0, 2.0, 3.0])
>>> pmf_vals, moments = model(theta, times)
>>>
>>> print(f"PMF at times: {pmf_vals}")
>>> print(f"Moments: {moments}") # [E[T], E[T^2]]
>>>
>>> # Compute reward-transformed moments
>>> rewards = jnp.array([1.0, 2.0, 0.5, 1.5]) # One per vertex
>>> pmf_vals, reward_moments = model(theta, times, rewards=rewards)
>>> print(f"Reward moments: {reward_moments}") # [E[R·T], E[R·T^2]]
>>>
>>> # Use in SVGD with moment regularization
>>> svgd = SVGD(model, observed_pmf, theta_dim=1)
>>> svgd.fit_regularized(observed_times=data, nr_moments=2, regularization=1.0)Notes
- More efficient than separate calls to pmf_from_graph() and moments_from_graph()
- Required for using moment-based regularization in SVGD.fit_regularized()
- The moments are always computed from the same graph used for PMF/PDF
- Import phasic before importing jax / creating jax arrays. phasic enables
jax_enable_x64on import; the C FFI requires float64 buffers. jax arrays created beforeimport phasicare float32 and trip the FFI check “Wrong buffer dtype: expected F64 but got F32”. If you hit this, restart the kernel and import phasic first (or recreate the arrays).
pmf_and_moments_from_graph_multivariate
phasic.Graph.pmf_and_moments_from_graph_multivariate(
graph,
nr_moments=2,
discrete=False,
use_ffi=False,
theta_dim=None,
)Create a multivariate phase-type model that handles 2D observations and rewards.
This wrapper enables computing joint likelihoods for multivariate phase-type distributions where each feature dimension has its own reward vector defining the marginal distribution.
Parameters
graph :Graph-
Parameterized graph built using the Python API with parameterized edges.
nr_moments :int= 2-
Number of moments to compute per feature dimension
discrete :bool= False-
If True, computes discrete PMF. If False, computes continuous PDF.
use_ffi :bool= False-
If True, uses Foreign Function Interface approach.
theta_dim :int= None-
Number of parameters for parameterized edges.
Returns
:callable-
JAX-compatible function with signature: model(theta, times, rewards=None) -> (pmf_values, moments) Where: - theta: Parameter vector (theta_dim,) - times: Time points - can be 1D (n_times,) or 2D (n_times, n_features) - rewards: Reward vectors - can be: * None: Standard moments * 1D (n_vertices,): Single reward vector (backward compatible) * 2D (n_vertices, n_features): Multivariate - one reward per feature - pmf_values: PMF/PDF values - shape matches input: * 1D rewards → 1D output (n_times,) * 2D rewards → 2D output (n_times, n_features) - moments: Moment values: * 1D rewards → 1D output (nr_moments,) * 2D rewards → 2D output (n_features, nr_moments)
Examples
>>> # Create parameterized model
>>> graph = Graph(coalescent, nr_samples=3)
>>> model = Graph.pmf_and_moments_from_graph_multivariate(graph, nr_moments=2)
>>>
>>> # 1D case (backward compatible)
>>> theta = jnp.array([0.5])
>>> times = jnp.array([1.0, 2.0, 3.0])
>>> rewards_1d = jnp.array([1.0, 2.0, 0.5, 1.5]) # (n_vertices,)
>>> pmf_vals, moments = model(theta, times, rewards_1d)
>>> print(pmf_vals.shape) # (3,)
>>> print(moments.shape) # (2,)
>>>
>>> # 2D case (multivariate)
>>> rewards_2d = jnp.array([[1.0, 2.0, 0.5, 1.5], # Feature 1 reward vector
... [0.5, 1.0, 2.0, 0.8]]) # Feature 2 reward vector
... # (n_features, n_vertices)
>>> times_2d = jnp.array([[1.0, 1.5],
... [2.0, 2.5],
... [3.0, 3.5]]) # (n_times, n_features)
>>> pmf_vals, moments = model(theta, times_2d, rewards_2d)
>>> print(pmf_vals.shape) # (3, 2)
>>> print(moments.shape) # (2, 2)
>>>
>>> # Use in SVGD with 2D observations
>>> observed_data = jnp.array([[1.5, 2.1], [0.8, 1.2], [2.3, 3.1]])
>>> svgd = SVGD(model, observed_data, theta_dim=1, rewards=rewards_2d)
>>> results = svgd.optimize()Notes
- For 2D rewards, each feature dimension is computed independently using the corresponding row of the rewards matrix (rewards[j, :] for feature j)
- Log-likelihood is computed as sum over all observation elements
- Backward compatible: 1D rewards behave exactly as pmf_and_moments_from_graph()
pmf_from_cpp
phasic.Graph.pmf_from_cpp(cpp_file, discrete=False)Load a phase-type model from a user’s C++ file and return a JAX-compatible function.
The C++ file should include ‘user_model.h’ and implement:
phasic::Graph build_model(const double* theta, int n_params) { // Build and return Graph instance }
For efficient repeated evaluations with the same parameters without JAX overhead, use load_cpp_builder() instead to get a builder function that creates Graph objects.
Parameters
cpp_file :strorpathlib.Path-
Path to the user’s C++ file
discrete :bool= False-
If True, uses discrete phase-type distribution (DPH) computation. If False, uses continuous phase-type distribution (PDF).
Raises
:ImportError-
If JAX is not installed. Install with: pip install jax jaxlib
:FileNotFoundError-
If the specified C++ file does not exist
Returns
:callable-
JAX-compatible function (theta, times) -> pmf_values that supports JIT, grad, vmap, etc.
Examples
JAX-compatible approach (default - for SVGD, gradients, optimization):
>>> model = Graph.pmf_from_cpp("my_model.cpp")
>>> theta = jnp.array([1.0, 2.0])
>>> times = jnp.linspace(0, 10, 100)
>>> pmf = model(theta, times)
>>> gradient = jax.grad(lambda p: jnp.sum(model(p, times)))(theta)Discrete phase-type distribution:
>>> model = Graph.pmf_from_cpp("my_model.cpp", discrete=True)
>>> theta = jnp.array([1.0, 2.0])
>>> jumps = jnp.array([1, 2, 3, 4, 5])
>>> dph_pmf = model(theta, jumps)For direct C++ access without JAX (faster for repeated evaluations):
>>> builder = load_cpp_builder("my_model.cpp")
>>> graph = builder(np.array([1.0, 2.0])) # Build graph once
>>> pdf1 = graph.pdf(1.0) # Use many times
>>> pdf2 = graph.pdf(2.0) # No rebuild neededpmf_from_graph
phasic.Graph.pmf_from_graph(
graph,
discrete=False,
use_cache=True,
theta_dim=None,
)Convert a Python-built Graph to a JAX-compatible function with full gradient support.
This method automatically detects if the graph has parameterized edges (edges with state vectors) and generates optimized C++ code to enable full JAX transformations including gradients, vmap, and jit compilation.
For direct C++ access without JAX wrapping, use the Graph object’s methods directly: graph.pdf(time), graph.dph_pmf(jump), graph.moments(power), etc.
Raises
:ImportError-
If JAX is not installed. Install with: pip install jax jaxlib
Parameters
graph :Graph-
Graph built using the Python API. Can have regular edges or parameterized edges.
discrete :bool= False-
If True, uses discrete phase-type distribution (DPH) computation. If False, uses continuous phase-type distribution (PDF).
use_cache :bool= True-
If True, uses symbolic DAG cache to avoid re-computing expensive symbolic elimination for graphs with the same structure. Default: True Set to False to disable caching (useful for testing).
Returns
:callable-
If graph has parameterized edges: JAX-compatible function (theta, times) -> pmf_values Supports JIT, grad, vmap, etc. If graph has no parameterized edges: JAX-compatible function (times) -> pmf_values Supports JIT (backward compatible signature)
Examples
Non-parameterized graph (regular edges only)
>>> g = Graph(1)
>>> start = g.starting_vertex()
>>> v0 = g.find_or_create_vertex([0])
>>> v1 = g.find_or_create_vertex([1])
>>> start.add_edge(v0, 1.0)
>>> v0.add_edge(v1, 2.0) # fixed weight
>>>
>>> model = Graph.pmf_from_graph(g)
>>> times = jnp.linspace(0, 5, 50)
>>> pdf = model(times) # No theta neededParameterized graph (with edge states for gradient support)
>>> g = Graph(1)
>>> start = g.starting_vertex()
>>> v0 = g.find_or_create_vertex([0])
>>> v1 = g.find_or_create_vertex([1])
>>> start.add_edge(v0, 1.0)
>>> v0.add_edge_parameterized(v1, 0.0, [2.0, 0.5]) # weight = 2.0*theta[0] + 0.5*theta[1]
>>>
>>> model = Graph.pmf_from_graph(g)
>>> theta = jnp.array([1.0, 3.0])
>>> pdf = model(theta, times) # weight becomes 2.0*1.0 + 0.5*3.0 = 3.5
>>>
>>> # Full JAX support for parameterized graphs
>>> grad_fn = jax.grad(lambda t: jnp.sum(model(t, times)))
>>> gradient = grad_fn(theta) # Gradients work!For direct C++ access (no JAX overhead), use graph methods:
>>> pdf_value = g.pdf(1.5) # Direct C++ call
>>> pmf_value = g.dph_pmf(3) # Direct C++ callWith symbolic DAG caching (default)
>>> model = Graph.pmf_from_graph(g, use_cache=True) # First call: computes and caches
>>> model2 = Graph.pmf_from_graph(g, use_cache=True) # Subsequent: instant from cache!pmf_from_graph_joint_index
phasic.Graph.pmf_from_graph_joint_index(
graph,
theta_dim=None,
fixed_mask=None,
exclude_vertices=None,
observed_indices=None,
)Create a JAX-compatible model for joint index distributions.
In joint index mode, likelihood is computed from exact expected sojourn times rather than PDF/PMF values. The observed_data should contain vertex indices (integers) instead of time values.
This is used for joint index distributions in population genetics models where the observed data represents which states (vertices) were visited.
Uses the fast expected_sojourn_time() method which computes exact sojourn times for all states in a single pass through the elimination trace.
Parameters
graph :Graph-
Parameterized graph built using the Python API with parameterized edges.
theta_dim :int= None-
Number of parameters for parameterized edges. If not provided, will be auto-detected from the graph.
fixed_mask :jnp.ndarray= None-
Binary mask indicating which parameters are fixed (1=fixed, 0=learnable). If provided, gradients for fixed dimensions will be zero in the custom VJP. This is used to skip finite difference computation for fixed parameters.
exclude_vertices : list of int = None-
Vertex indices to exclude from the normalization constant. Use this for ascertainment bias correction — e.g., exclude the zero-mutation terminal state when conditioning on observing at least one mutation. The excluded vertices are removed from the denominator, so the model returns P(s | s not in excluded set).
observed_indices : array-like of int = None-
When supplied, enables baked observation dedup: the model will be built around the unique vertex indices in
observed_indices(typically far fewer than the number of observations, with many repeats). The FFI sojourn-times call seesk = n_uniqueinstead ofk = n_obs(the FFI consumer’s inner loop scales linearly ink), and the returned per-observation array is reconstructed via a scatter through the inverse-index mapping. The model also wraps its forward in acustom_vmaprule so that undervmap(grad(loss))(particles)the per-particle dispatch fuses into a single batched FFI call. Whenobserved_indicesisNone(default), the legacy path is used andvertex_indicesis read from the model’s runtime argument as before.
Returns
:callable-
JAX-compatible function with signature: model(theta, vertex_indices, rewards=None) -> (sojourn_times, dummy_moments) Where: - theta: Parameter vector - vertex_indices: Array of vertex indices (integers). Ignored when
observed_indiceswas supplied at construction time; the baked indices are used instead. - rewards: Ignored (must be None for joint_index mode) - sojourn_times: Expected sojourn times for the specified vertices - dummy_moments: Zeros array (moments not supported in joint_index mode)
Notes
- Uses expected_sojourn_time() for fast exact computation
- Much faster than iterating accumulated_visiting_time() until convergence
- Moment regularization is not supported (regularization must be 0)
- Reward transformation is not supported (rewards must be None)
- When
observed_indicesis supplied, dedup typically gives a 10-100x wall-clock win for SVGD on joint-prob graphs with many repeated observations. SeeGraph.svgdjoint_index path.
pmf_from_graph_parameterized
phasic.Graph.pmf_from_graph_parameterized(graph_builder, discrete=False)Convert a parameterized Python graph builder to a JAX-compatible function.
This allows users to define parameterized models where the graph structure or edge weights depend on parameters.
Parameters
graph_builder :callable-
Function (theta) -> Graph that builds a graph with given parameters
discrete :bool= False-
If True, uses discrete phase-type distribution (DPH) computation. If False, uses continuous phase-type distribution (PDF).
Returns
:callable-
JAX-compatible function (theta, times) -> pdf_values that supports JIT, grad, vmap, etc.
Examples
>>> def build_exponential(rate):
... g = Graph(1)
... start = g.starting_vertex()
... v0 = g.find_or_create_vertex([0])
... v1 = g.find_or_create_vertex([1])
... start.add_edge(v0, 1.0)
... v0.add_edge(v1, float(rate))
... return g
>>>
>>> model = Graph.pmf_from_graph_parameterized(build_exponential)
>>> theta = jnp.array([1.5])
>>> times = jnp.linspace(0, 5, 50)
>>> pdf = model(theta, times)prewarm_cache
phasic.Graph.prewarm_cache()Populate the on-disk Stage A2 cache for this graph. If parameterized, update_weights must be called beforehand.
Triggers the C-side symbolic elimination (ptd_precompute_reward_compute_graph) and writes its result to ~/.phasic_cache/parameterized_reward_compute/<content_hash>.bin. Subsequent processes (notebook restart, SLURM workers, fresh CLI runs) that build the same graph will load the cached elimination instead of redoing the O(n^3) work.
If phasic.configure(parallel_elimination=True) is active and the graph is parameterised, the hierarchical SCC composer takes over: it writes one scc_<synth_hash>.bin entry per SCC large enough to cache (PHASIC_MIN_SCC_SIZE_TO_CACHE) and skips the monolithic parent <content_hash>.bin. Future runs that want to benefit from the cache must also have parallel_elimination=True active — the parent file and SCC files are populated by different code paths and not interchangeable. Run prewarm_cache once under each configuration you plan to consume from.
Notes
Cost is one full elimination plus one waiting-time read; the cache write is a side effect of the elimination.
Examples
Pre-warm on the head node before farming out to SLURM workers:
>>> g = Graph(model, indexer=indexer, theta_dim=2)
>>> g.prewarm_cache()
>>> # ~/.phasic_cache/parameterized_reward_compute/<hash>.bin now exists
>>> # Workers that rebuild the same graph will load it instantlyPre-warm SCC cache too (cyclic graph):
>>> from phasic import configure
>>> with configure(parallel_elimination=True):
... g.prewarm_cache(theta=[1.0, 0.5])
... # scc_*.bin entries are now also on diskprobability_matching
phasic.Graph.probability_matching(
observed_data,
fixed=None,
theta_dim=None,
theta_init=None,
std_multiplier=2.0,
verbose=True,
)Find parameter estimates by matching model probabilities to empirical probabilities.
For joint probability graphs (created via :meth:joint_prob_graph), observations are feature-count tuples and the model outputs a probability table. This method minimises the squared difference between model and empirical probabilities.
The returned ProbMatchResult.prior list can be passed directly to :meth:Graph.svgd as the prior argument.
Parameters
observed_data : list of tuples-
Observed feature-count tuples, e.g.
[(0, 1, 0), (1, 0, 0), ...]. Each tuple must match a row in the joint probability table. fixed :list= None-
List of
(index, value)tuples pinning specific parameters. theta_dim :int= None-
Number of model parameters. Inferred from the graph when
None. theta_init :np.ndarray= None-
Initial guess for the free parameters (excluding fixed ones). If
Nonea coordinate-wise grid search is used. std_multiplier :float= 2.0-
Factor applied to the asymptotic standard error to obtain the prior standard deviation:
prior_std = std_multiplier * se. verbose :bool= True-
Print progress information.
Returns
:ProbMatchResult-
Dataclass with
theta,std,prior,empirical_probs,model_probs,unique_indices, etc.
Raises
:ValueError-
If the graph is not a joint probability graph.
Examples
>>> jg = graph.joint_prob_graph(indexer, tot_reward_limit=2)
>>> jg.update_weights([1.0, 0.5])
>>> obs = [tuple(row) for row in jg.joint_prob_table().iloc[:, :-1].values]
>>> result = jg.probability_matching(obs, fixed=[(1, 0.5)])
>>> svgd = jg.svgd(obs, prior=result.prior, fixed=[(1, 0.5)])profile
phasic.Graph.profile(theta=None, probe_dyn='auto')Profile this graph and recommend parallel_elimination, dyn_ordering, and the evaluation path (forward-PDF vs joint/sojourn).
Thin wrapper around :func:phasic.profile_graph; see that function for details. Returns a :class:~phasic.profile.GraphProfile.
print(graph.profile())
pull_cache
phasic.Graph.pull_cache(force=False)Download the published compute artifact for this graph if available.
Looks up this graph’s content hash in the munch-group/phasic-traces registry. On a hit, downloads the parent .bin (and any per-SCC files) to ~/.phasic_cache/parameterized_reward_compute/ so the next call to :meth:expectation / :meth:pdf / :meth:moments reuses the published elimination instead of recomputing.
No git, gh, or daemon required for the consumer side — fetches are plain HTTPS to raw.githubusercontent.com.
Parameters
force :bool= False-
If True, re-download even when a local cache file already exists.
Returns
:bool-
True if a fresh download succeeded or a local cache file was already present. False if no registry entry matches this graph’s content hash.
Raises
:phasic.exceptions.PTDBackendError-
On network failure or SHA-256 mismatch.
:phasic.exceptions.PTDFormatError-
If the artifact’s
format_revisionexceeds what this phasic build supports.
Examples
>>> g = phasic.Graph(my_callback, ipv=[5])
>>> if g.pull_cache():
... print('reusing published elimination')
>>> e = g.expectation()push_cache
phasic.Graph.push_cache(
id,
description,
domain=None,
model_type=None,
tags=None,
license='MIT',
author=None,
registry_repo='munch-group/phasic-traces',
dry_run=False,
overwrite_branch=False,
)Publish this graph’s compute artifact to the phasic-traces registry.
Populates the C-side elimination cache if necessary (calls :meth:expectation), saves the parent .bin and any per-SCC files, clones the registry repo to a temporary directory, splices in a new entry, pushes a feature branch, and opens a pull request via gh.
Parameters
id :str-
Human-readable identifier for the registry entry (e.g.
'coal_n5_theta1'). Must be unique within the registry. description :str-
One-line free-form description of the model.
domain :str= None-
Filtering keys stored in the entry’s metadata.
model_type :str= None-
Filtering keys stored in the entry’s metadata.
tags :list[str] = None-
Tags stored in the entry’s metadata.
license :str= 'MIT'-
SPDX license identifier.
author :str= None-
Override; default is
"Name <email>"fromgit config user.{name,email}. registry_repo :str= 'munch-group/phasic-traces'-
GitHub
owner/nameof the registry repository. dry_run :bool= False-
If True, build the artifacts and return a JSON string of the would-be entry without cloning or pushing.
overwrite_branch :bool= False-
If a stale
phasic-publish/<id>branch already exists on the remote (e.g. from an earlier failed push) the call refuses unless this isTrue. WithTrue, the push uses--force-with-lease: it overwrites the stale branch but still refuses if a third party has pushed concurrently.
Returns
:str-
URL of the opened PR (or, if
dry_run=True, a JSON string).
Raises
:phasic.exceptions.PTDBackendError-
If
ghis missing or unauthenticated, the entry id already exists inregistry.json, the feature branch already exists on the remote (withoutoverwrite_branch=True), or git/gh fails.
Notes
Running gh auth login once in a terminal sets up authentication for all future push_cache() calls. push_cache does not prompt inside the notebook.
Examples
>>> g = phasic.Graph(my_callback, ipv=[5])
>>> g.expectation() # populate C-side cache (optional; push_cache does this)
>>> entry_json = g.push_cache(
... id='my_model_v1',
... description='Kingman coalescent for n=5',
... domain='population-genetics',
... model_type='coalescent',
... dry_run=True,
... )reward_transform
phasic.Graph.reward_transform(rewards, *, validate_rewards=True)Apply reward transformation to create a new graph with modified rewards.
Parameters
rewards :np.ndarray-
Reward vector of length n_vertices. Each element specifies the reward associated with visiting the corresponding vertex.
validate_rewards :bool= True-
If True, run the coverage check and emit a
UserWarningwhen some trajectories accumulate zero reward — i.e. the transformed distribution has a point mass at :math:r = 0. The transformation always proceeds regardless: an (atom + continuous) mixture is a legitimate phase-type distribution, useful for Laplace transforms, conditional expectations, and zero-inflated likelihood inference viaGraph.svgd. SetFalseto silence the warning. Shape errors are always raised regardless of this flag.
Returns
:Graph-
New graph with reward-transformed distribution.
Notes
Reward transformation is used to compute moments and expectations for different reward structures. The transformation modifies the graph to compute E[∑ rewards[i] * time_in_state_i] instead of E[T].
For continuous distributions: Uses continuous reward transformation. For discrete distributions: Uses discrete reward transformation.
The returned graph is a new Graph object (not modified in-place).
See Also
moments : Compute moments with optional reward vector expectation : Compute expectation with optional reward vector
reward_transform_discrete
phasic.Graph.reward_transform_discrete(rewards)Apply reward transformation for discrete-time distributions.
Parameters
rewards :np.ndarray-
Reward vector of length n_vertices.
Returns
:Graph-
New graph with discrete reward transformation applied.
Notes
This method is specific to discrete-time phase-type (DPH) distributions. For automatic dispatch, use reward_transform() instead.
See Also
reward_transform : General reward transformation (dispatches to this for discrete graphs)
reward_visit_probability
phasic.Graph.reward_visit_probability(rewards, theta=None)Probability of visiting any rewarded vertex before absorption.
For a 1D reward vector rewards and (parameterized) graph at parameter theta, returns the scalar probability that a trajectory absorbed from the starting vertex visits at least one vertex with reward > 0 before being absorbed. This is the phase-type quantity
p(theta) = sum_{v in starts} alpha_v * h_v(theta)
where h_v(theta) = P(reach a rewarded vertex | start at v) and alpha is the IPV. h is computed via Graph.backward_probabilities using the rewarded vertices as the target set; the result depends only on graph topology, IPV, and theta — NOT on observed reward times.
Parameters
rewards : (array-like,shape(n_vertices))-
Per-vertex reward; only the nonzero pattern is used.
theta :array-like= None-
Parameter vector. If None, uses the graph’s current parameter setting. If a JAX tracer is passed, the JAX-differentiable FFI path is used and the return is a jax.Array. Otherwise the result is a Python float.
Returns
: (floatorjax.Array,shape())-
Probability p(theta) in [0, 1].
Notes
When p < 1, some absorbing trajectories accumulate zero cumulative reward. The induced reward distribution is then a mixture (point mass at 0 + continuous part for r > 0); use Graph.svgd(..., rewards=...) to fit it with a zero-inflated likelihood automatically.
sample
phasic.Graph.sample(n, *, validate_rewards=True, **kwargs)Generate random samples from the phase-type distribution.
Parameters
n :int-
Number of samples to generate.
validate_rewards :bool= True-
If True and
rewards=is provided inkwargs, run the coverage check (BFS for a trajectory that skips every rewarded vertex). A coverage failure means the reward-transformed distribution has a point mass at :math:r = 0;Graph.samplewill then return some zero-valued samples and emits aUserWarningso the user knows the mixture shape. Shape errors are always raised regardless of this flag — wrong-length rewards are bugs, not modelling choices. ****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation, notably
rewards=(a per-vertex reward vector).
Returns
:np.ndarray-
Array of n samples from the distribution.
Notes
Sampling is done by simulating the underlying Markov chain until absorption. For more efficient repeated sampling, first create a distribution context using distribution_context().
sample_path
phasic.Graph.sample_path(n=1)Sample complete path(s) through the Markov chain.
Simulates the underlying Markov chain from the starting vertex until absorption, recording every vertex visited and the cumulative time at which each vertex was entered.
Parameters
n :int= 1-
Number of paths to sample.
Returns
: dict or list of dict-
If n=1, returns a single dict with keys: - ‘vertex_indices’: np.ndarray of vertex indices visited - ‘entry_times’: np.ndarray of cumulative entry times If n>1, returns a list of such dicts.
Notes
The first entry is always the starting vertex with entry_time=0. The last entry is the absorbing vertex. Sojourn times can be computed as the differences between consecutive entry times.
Examples
>>> g = Graph(...) # build graph
>>> path = g.sample_path()
>>> path['vertex_indices'] # array of visited vertex indices
>>> path['entry_times'] # cumulative times
>>> sojourn_times = np.diff(path['entry_times'])sample_path_conditioned
phasic.Graph.sample_path_conditioned(target_vertices, n=1)Sample path(s) conditioned on reaching a target terminal state.
Uses guided forward sampling: at each step, the next state is chosen proportional to edge_weight * h(next_state) where h is the backward probability of reaching the target. This ensures every sampled path ends at one of the target vertices.
Parameters
target_vertices : list of int-
Indices of target terminal vertices.
n :int= 1-
Number of paths to sample.
Returns
: dict or list of dict-
Same format as sample_path().
serialize
phasic.Graph.serialize(theta_dim=None)Serialize graph to array representation for efficient computation.
Parameters
theta_dim :int= None-
Number of parameters for parameterized edges. If not provided, will be auto-detected by probing edge states. Providing this explicitly avoids potential issues with auto-detection reading garbage memory.
Returns
:dict-
Dictionary containing: - ‘states’: Array of vertex states (n_vertices, state_dim) - ‘edges’: Array of regular edges [from_idx, to_idx, weight] (n_edges, 3) - ‘start_edges’: Array of starting vertex regular edges [to_idx, weight] (n_start_edges, 2) - ‘param_edges’: Array of parameterized edges [from_idx, to_idx, x1, x2, …] (n_param_edges, theta_dim+2) - ‘start_param_edges’: Array of starting vertex parameterized edges [to_idx, x1, x2, …] (n_start_param_edges, theta_dim+1) NOTE: start_param_edges should be empty (starting edges are not parameterized) - ‘param_length’: Length of parameter vector (0 if no parameterized edges) - ‘state_length’: Integer state dimension - ‘n_vertices’: Number of vertices
state_probability
phasic.Graph.state_probability(time, **kwargs)Compute probability of being in each state at a given time.
Parameters
time :floatorint-
Time point (continuous) or jump number (discrete) at which to evaluate state probabilities.
****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation.
Returns
:np.ndarray-
Probability of being in each state at the specified time.
Notes
For continuous distributions: probability vector at time t For discrete distributions: probability vector after n jumps Computed via matrix exponentiation or uniformization.
stop_probability
phasic.Graph.stop_probability(time, **kwargs)Compute probability of being in each state at a given time.
Parameters
time :floatorint-
Time point (continuous) or jump number (discrete) at which to evaluate state probabilities.
****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation.
Returns
:np.ndarray-
Probability of being in each state at the specified time.
Notes
For continuous distributions: probability vector at time t For discrete distributions: probability vector after n jumps Computed via matrix exponentiation or uniformization.
svgd
phasic.Graph.svgd(
observed_data,
discrete=None,
prior=None,
n_particles=None,
n_iterations=1000,
optimizer=None,
learning_rate=None,
bandwidth='median_per_dim',
theta_init=None,
theta_dim=None,
return_history=True,
seed=None,
verbose=False,
progress=True,
jit=None,
parallel=None,
n_devices=None,
precompile=True,
compilation_config=None,
regularization=0.0,
nr_moments=2,
positive_params=True,
param_transform=None,
joint_index=False,
rewards=None,
fixed=None,
tied=None,
callback=None,
preconditioner='auto',
epoch_starts=None,
daisy_chain_t_eval=None,
daisy_chain_granularity=0,
daisy_chain_probe_theta=None,
daisy_chain_t_eval_tol=0.001,
exposure=None,
exposure_param_index=None,
validate_rewards=True,
)Run Stein Variational Gradient Descent (SVGD) inference for Bayesian parameter estimation.
SVGD finds the posterior distribution p(theta | data) by optimizing a set of particles to approximate the posterior. This method works with parameterized models created by pmf_from_graph() or pmf_from_cpp() where the model signature is model(theta, times).
Parameters
observed_data :arrayorSparseObservations-
Observed data. A plain 1-D array is accepted for univariate models (must not contain NaN). For multivariate models use
SparseObservations(seedense_to_sparse()), in which caserewardsmust also be provided. discrete :bool= None-
If True, computes discrete PMF. If False, computes continuous PDF. If undefined it is inferred from the graph.is_discrete attribute.
prior : callable or list of Prior objects = None-
Log prior function for parameters. Can be: - Single callable: prior(theta) -> scalar, applied to entire theta vector - List of Prior objects: One prior per parameter dimension. Use None for fixed parameters: prior=[GaussPrior(ci=[0,1]), None, GaussPrior(ci=[0,1])] - DataPrior instance: Data-informed prior estimated from the observed data. If None (default), constructs
DataPrior(graph, observed_data, sd=5)which uses method-of-moments (or probability matching for joint probability graphs) to estimate prior means from the data, with a wide spread (5x the asymptotic standard error). Falls back to standard normal if DataPrior construction fails. Note: this data-driven default differs from the lower-levelSVGDclass, whoseprior=Noneis a plain standard normal (-0.5 * sum(theta**2)). Pass an explicitprior=to either API for identical behaviour. With fixed parameters: When using a list of priors with thefixedparameter, you must provide None at indices corresponding to fixed parameters. This is validated at initialization. Example: prior=[GaussPrior(ci=[0,1]), None, GaussPrior(ci=[0,1])], fixed=[(1, 0.5)] # theta[1] fixed, prior[1] must be None n_particles :intor None = None-
Number of SVGD particles.
Noneresolves to20 * theta_dim. More particles = better posterior approximation but slower. n_iterations :int= 1000-
Number of SVGD optimization steps
optimizer :object= None-
Learning rate optimizer instance from phasic.optimizers. Default is Adam when learning_rate=None and regularization=0. Options include Adamelia, Adam, SGDMomentum, RMSprop, Adagrad. When an optimizer is used, the learning_rate parameter is ignored (the optimizer has its own learning rate).
learning_rate :floator None = None-
SVGD step size. If None (default), uses Adame optimizer with adaptive learning rates. If a float is provided, uses fixed learning rate approach. Larger values = faster convergence but may be unstable.
bandwidth : str, float, or np.ndarray = 'median_per_dim'-
Kernel bandwidth selection method: - ‘median_per_dim’: Per-dimension median heuristic (default). Uses a separate bandwidth per parameter dimension for an anisotropic kernel. - ‘median’: Scalar median heuristic (isotropic kernel) - float: Fixed scalar bandwidth value - np.ndarray: Fixed per-dimension bandwidth vector
theta_init :ArrayLike= None-
Initial particle positions (n_particles, theta_dim). If None, initializes randomly from standard normal.
theta_dim :int= None-
Dimension of theta parameter vector. Can be: - Set at graph construction: Graph(callback, theta_dim=2) - Overridden here for SVGD inference (if graph was modified/augmented) If None, inferred from the graph’s parameterized edge structure via param_length(). Only required if theta_init is None and the graph has no parameterized edges. The value specified here overrides any theta_dim set during graph construction, which is useful if you’ve modified the graph structure (e.g., via extend()).
return_history :bool= True-
If True, return particle positions throughout optimization
seed :int= None-
Random seed for reproducibility
verbose :bool= False-
Print progress information
jit :boolor None = None-
Enable JIT compilation. If None, uses value from phasic.get_config().jit. JIT compilation provides significant speedup but adds initial compilation overhead.
parallel :stror None = None-
Parallelization strategy: - ‘vmap’: Vectorize across particles (single device) - ‘pmap’: Parallelize across devices (uses multiple CPUs/GPUs) - ‘none’: No parallelization (sequential, useful for debugging) - None: Auto-select (pmap if multiple devices, vmap otherwise)
n_devices :intor None = None-
Number of devices to use for pmap. Only used when parallel=‘pmap’. If None, uses all available devices.
precompile :bool= True-
(Deprecated: use jit parameter instead) Precompile model and gradient functions for faster execution. First run will take longer but subsequent iterations will be much faster.
compilation_config : CompilationConfig, dict, str, or pathlib.Path = None-
JAX compilation optimization configuration. Can be: - CompilationConfig object from phasic.CompilationConfig - dict with CompilationConfig parameters - str/Path to JSON config file - None (uses default balanced configuration)
positive_params :bool= True-
If True, applies softplus transformation to ensure all parameters are positive. Recommended for phase-type models where parameters represent rates. SVGD operates in unconstrained space, but model receives positive parameters.
param_transform :callable= None-
Custom parameter transformation function: transform(theta_unconstrained) -> theta_constrained. If provided, SVGD optimizes in unconstrained space and applies this transformation before calling the model. Cannot be used together with positive_params. Example: lambda theta: jnp.concatenate([jnp.exp(theta[:1]), jax.nn.softplus(theta[1:])])
joint_index :bool= False-
If True, use joint index mode where observed_data contains vertex indices (integers) instead of time values. In this mode, likelihood is computed from converged accumulated_visits() values rather than PDF/PMF values. This is used for joint index distributions in population genetics models. When joint_index=True: - observed_data should contain vertex indices (integers) - Forces discrete=True behavior - Moment regularization is not supported (raises NotImplementedError if regularization > 0)
rewards :ArrayLike= None-
Reward vectors for computing reward-transformed likelihoods. Can be: - None: Standard phase-type likelihood (default) - 1D array (n_vertices,): Single reward vector for univariate models - 2D array (n_vertices, n_features): Multivariate rewards - one reward vector per feature dimension. Requires use of pmf_and_moments_from_graph_multivariate() model. For multivariate models, observed_data should also be 2D (n_times, n_features).
fixed : list of (index, value) tuples or 1D array = None-
Pin selected parameters at known constants so SVGD only optimises the learnable dimensions. Equivalent to a point-mass prior at
valueon the pinned slots, with those positions removed from the kernel and the gradient computation. Two accepted forms: (a) Index/value tuples (recommended) —[(idx, value), ...]. Each tuple pins parameteridxatvalue:: fixed=[(1, 0.01)] # theta[1] = 0.01, rest learned fixed=[(0, 2.5), (2, 0.1)] # theta[0]=2.5, theta[2]=0.1 (b) Binary mask (legacy) — a 1D array of lengththeta_dimwhere1pins the slot at the value1.0and0leaves it learnable. Use form (a) whenever the fix value is not1.0:: fixed=[0, 1] # theta[1] pinned at 1.0 When to use. Fix a parameter when its value is known from prior data, when you want to test sensitivity to a single dimension while holding the rest at MLE, or when a slot is structurally unidentifiable from the observed data alone (e.g. a global rate scale that the model absorbs into another parameter). Interaction withprior. Ifprioris a per-slot list, the entries at fixed indices must beNone; mismatches raise at construction time. A scalarpriorcallable is fine and is auto-masked at the fixed slots. Daisy-chain semantics (epoch_starts=[...]). The flattened theta has shape(n_epochs * param_length,), butfixedentries still use the local per-epoch index ([0, param_length)) and are broadcast across all epochs. To pin a parameter at different values per epoch, pass a list/array of lengthn_epochsas the value:: fixed=[(1, 1.0)] # local_idx=1 pinned at 1.0 in EVERY epoch fixed=[(1, [1.0, 2.5])] # local_idx=1: 1.0 in epoch 0, 2.5 in epoch 1 fixed=[(0, 5.0), (1, [2.0, 8.0])] # mix scalar and per-epoch values Combination withtied. Compatible, with one rule: a given(local_idx, epoch)slot may be eitherfixedor a member of atiedgroup, not both. Overlaps raise at construction time (rule R20). tied : list of (local_idx, [epoch_a, epoch_b, …]) tuples = None-
Daisy-chain only. Tie a parameter slot across two or more epochs so SVGD treats them as a single learnable value. Within each entry the first epoch is the master (the slot SVGD actually optimises); every subsequent epoch is a slave whose value is replaced with the master’s on every forward evaluation, and whose gradient is routed back into the master. Examples:: tied=[(0, [0, 1])] # local_idx 0 shared across epochs 0 and 1 tied=[(1, [0, 2, 3])] # local_idx 1 shared across epochs 0, 2, 3 tied=[(0, [0, 1]), (1, [1, 2])] # two independent ties When to use. Use
tiedwhen a population parameter (e.g. mutation rate per base, baseline hazard) is biologically constant across a subset of epochs while other parameters change. Tying reduces dimensionality, improves identifiability, and makes posteriors tighter without silently fusing epochs whose other parameters should differ. Requirements. - Requiresepoch_starts=...(rule R16); a tie within a single epoch is meaningless and rejected with rule R17. -local_idxis the per-epoch index in[0, param_length)(rule R18); the epoch indices are in[0, n_epochs). - Each epoch index may appear at most once per tied entry (rule R19). - Each(local_idx, epoch)slot may belong to at most one tied group, and may not also befixed(rule R20). Combination withfixed. Compatible as long as no slot is claimed by both — see the rule R20 note infixedabove. To pin a parameter at the same value in every epoch usefixed=[(local_idx, value)](broadcast). To pin a parameter at different values per epoch use the per-epoch list form offixed. Usetiedonly when the shared value should be learned rather than known a priori. Combination withexposure. Compatible. The exposed parameter (exposure_param_index) may itself be tied across epochs; the per-observation exposure scaling multiplies the tied (shared) value in every epoch where the slot appears as master or slave. Not available on the directSVGD(model=...)path — tying is a pre-model-construction concern owned byGraph.svgd. preconditioner : str, preconditioner instance, or None = 'auto'-
Preconditioning method for multi-scale parameters: - ‘auto’ or ‘jacobian’: Moment Jacobian preconditioning (default, recommended). Uses column norms of the moment Jacobian matrix for scaling. Simpler and more robust than Fisher preconditioning. - ‘fisher’: Fisher diagonal preconditioning. Uses empirical Fisher information matrix diagonal. Can be unstable when PMF values are small. - None or ‘none’: No preconditioning (original behavior) - MomentJacobianPreconditioner or FisherPreconditioner instance: Custom preconditioner
epoch_starts : array-like of float = None-
Enables daisy-chain (time-inhomogeneous) inference.
epoch_starts[0] == 0; subsequent entries are the start times of additional epochs.n_epochs = len(epoch_starts). Each epoch fits its ownparam_lengthparameters, so the flattened theta has lengthn_epochs * param_length. Requires a continuous-time joint-prob graph (discrete=False). Seefixedandtiedfor how those kwargs interpret indices under daisy-chain. daisy_chain_t_eval : float, str, or None = None-
Time at which the final-epoch joint stop-probabilities are read off the JSP graph’s t-vertices. Only used when
epoch_startsis set. - Numeric: used as-is. -None(default): falls back tomax(sum(epoch_dts) * 4, 10.0). -'auto': an adaptive probe (Graph._probe_daisy_t_eval) walks the daisy chain atdaisy_chain_probe_thetaand growst_evaluntil the residual non-t-vertex transient mass falls belowdaisy_chain_t_eval_tol. Typically picks a much smaller value than the conservative default and so cuts SVGD wall time significantly. daisy_chain_granularity :int= 0-
Uniformization granularity passed to
stop_probabilityinside the daisy-chain FFI handler.0= auto (the underlying C++ picks a safe value from the graph’s max rate × time). Larger values give finer discretisation; smaller positive values trade accuracy for speed. daisy_chain_probe_theta :array-like= None-
Theta used by the
daisy_chain_t_eval='auto'probe. Shape(param_length,)(broadcast across all epochs) or(n_epochs, param_length)(per-epoch). Defaults to ones. daisy_chain_t_eval_tol :float= 1e-3-
Residual-mass tolerance used by the
daisy_chain_t_eval='auto'probe. exposure : float, array-like, or None = None-
Per-observation exposure :math:
\alpha_i— a known multiplicative scaling on a rate-typed component of :math:\boldsymbol{\theta}. For observationithe model is evaluated at :math:\boldsymbol{\theta}^{(i)}where :math:\theta^{(i)}_j = \theta_jfor :math:j \neq kand :math:\theta^{(i)}_k = \theta_k \cdot \alpha_i, with :math:k=exposure_param_index. The exposed rate parameter and :math:\alpha_ijointly determine each observation’s expected event count (or hazard, or PMF, depending on the model). This is the GLM “exposure” / “offset” construct: it linearises the relationship between a rate parameter and an observation-specific outcome that scales with a known quantity. Concrete instances: - Coalescent-with-mutation: :math:\alpha_i= segment length :math:L_iin bases; :math:\theta_kis the per-base mutation rate. - Survival / failure-time: :math:\alpha_i= time-at-risk for unit :math:i; :math:\theta_kis the hazard rate. - Spatial Poisson: :math:\alpha_i= area or volume of region :math:i; :math:\theta_kis the intensity per unit area. Forms: -None(default): no exposure correction; existing behaviour. - scalar: same :math:\alphaapplied to every observation. - 1D array of lengthn_observations: per-observation :math:\alpha. For dense 2Dobserved_dataof shape(n_observations, n_features)the same :math:\alpha_iis shared across all features of observation :math:i. Requiresexposure_param_indexto be set. Not supported forSparseObservations(raisesNotImplementedError). exposure_param_index :intor None = None-
Index :math:
kof the rate-typed parameter in :math:\boldsymbol{\theta}thatexposurescales. Required whenexposureis set. Must be in[0, param_length). Under daisy-chain (epoch_starts=[…]), :math:\boldsymbol{\theta}has flat layout(n_epochs * param_length,);exposure_param_indexremains the local per-epoch index and is broadcast across every epoch internally.
Returns
:dict-
Inference results containing: - ‘particles’: Final posterior samples (n_particles, theta_dim) - ‘theta_mean’: Posterior mean estimate - ‘theta_std’: Posterior standard deviation - ‘history’: Particle evolution over iterations (if return_history=True)
Raises
:ImportError-
If JAX is not installed
:ValueError-
If model is not parameterized or theta_dim cannot be inferred
Examples
>>> import jax.numpy as jnp
>>> from phasic import Graph
>>>
>>> # Build parameterized coalescent model
>>> def coalescent_callback(state, nr_samples=3):
... if len(state) == 0:
... return [(np.array([nr_samples]), 1.0, [1.0])]
... if state[0] > 1:
... n = state[0]
... rate = n * (n - 1) / 2
... return [(np.array([n - 1]), 0.0, [rate])]
... return []
>>>
>>> g = Graph.from_callback_parameterized(coalescent_callback, nr_samples=4)
>>> model = Graph.pmf_from_graph(g, discrete=False)
>>>
>>> # Generate synthetic observed data
>>> true_theta = jnp.array([2.0])
>>> times = jnp.linspace(0.1, 3.0, 15)
>>> observed_pdf = model(true_theta, times)
>>>
>>> # Run SVGD inference
>>> results = Graph.svgd(
... observed_data=observed_pdf,
... theta_dim=1,
... n_particles=30,
... n_iterations=500,
... learning_rate=0.01
... )
>>>
>>> print(f"True theta: {true_theta}")
>>> print(f"Posterior mean: {results['theta_mean']}")
>>> print(f"Posterior std: {results['theta_std']}")Notes
- SVGD requires a parameterized model. Non-parameterized models (signature: model(times)) cannot be used for inference as there are no parameters to estimate.
- The likelihood is computed as sum(log(model(theta, observed_data)))
- For better results, ensure observed_data has sufficient information about the parameters
- Learning rate and number of iterations may need tuning for different problems
update_ipv
phasic.Graph.update_ipv(ipv)Set the initial probability vector after construction.
Parameters
ipv : (array-like,shape(n_ipv_edges))-
New weights for starting-vertex edges, in construction order. Length must equal the number of starting-vertex edges in the graph.
Notes
IPV is a property of the model, not an inference parameter. Use this method to:
- set the IPV after constructing the graph if your callback does not bake one in;
- re-run the same graph against a different initial distribution without rebuilding it;
- propagate epoch state in a daisy chain (handled internally by the daisy-chain machinery between epochs).
The symbolic compute graph cache (Stage A0) survives this call, so subsequent forward computations (expectation, pdf, compute_pmf, …) reuse the cached elimination.
For users on the Python EliminationTrace path (cache_trace=True), IPV remains baked in at trace-record time — that path is not affected by update_ipv.
update_weights
phasic.Graph.update_weights(theta, callback=None, log=False)Update parameterized edge weights with given parameters.
This method wraps the C++ implementation to cache theta for use with trace-based computation.
Parameters
theta :ArrayLike-
Parameter vector to set edge weights.
callback :callable= None-
Custom callback for weight computation.
log :bool= False-
If True, use log-space computation.
Notes
This updates edge weights on the live Graph object (C++ side). It does NOT invalidate the cached trace since it only changes parameter values, not graph structure.
For the JAX/FFI/SVGD pipeline, use graph.weight_mode instead. That property controls how pmf_from_graph() and pmf_and_moments_from_graph() compute edge weights from theta.
variance
phasic.Graph.variance(rewards=[], **kwargs)Compute variance of the phase-type distribution.
Parameters
rewards :ArrayLike= []-
Reward vector for reward-transformed variance. If not provided, computes Var(T) where T is time until absorption.
****kwargs** :dict= {}-
Additional keyword arguments passed to C++ implementation.
Returns
:float-
Variance Var(T) or reward-transformed variance.
Notes
For parameterized graphs, this method uses trace-based computation which requires O(n) memory instead of O(n²) for the matrix-based approach. Set cache_trace=True when creating the graph to cache the trace for faster repeated evaluations.
Computed as Var(T) = E[T^2] - E[T]^2 using moments.