Temporal Graphs

In PARCS, you can define a temporal graph where the nodes and relations are expanded along the time axis. This is possible by only making a few small modifications to the current pipeline. Let’s imagine the following scenario to explain this feature: The goal is to model the interaction between the blood pressure variable BP and the drug variable Drug. Initially, we let the blood pressure be dependent on gaussian noise Noise, while the drug depends on the age variable Age of the patient as well as the blood pressure. This setup can be modeled by the non-temporal version of PARCS.

a static graph description file
1Age: uniform(mu_=10, diff_=20)
2BP: normal(mu_=Noise, sigma_=1)
3Drug: bernoulli(p_=BPAge)
4Noise: normal(mu_=0, sigma_=1)
5
6Age->Drug: identity()
7Noise->BP: identity()
8BP->Drug: identity()

Temporal Syntax in the Graph Description

Now we discuss the temporal expansion: every temporal component is marked by a subscript where _{t} indicates the current timestep.

 1# BLVs
 2Age: uniform(mu_=10, diff_=20)
 3
 4# temporal recursive
 5BP_{0}: uniform(mu_=10, diff_=2) 
 6BP_{t}: normal(mu_=BP_{t-1}+Noise_{t}-Drug_{t-2}, sigma_=1)
 7Drug_{-1}: constant(0)
 8Drug_{0}: constant(0)
 9Drug_{t}: bernoulli(p_=BP_{t}+Drug_{t-1}+Age), correction[]
10
11# temporal non-recursive
12Noise_{t}: normal(mu_=0, sigma_=1)
13
14# interactions BLVs to temporal recursive
15Age->Drug_{t}: identity()
16
17# interactions temporal non-recursive to temporal recursive
18Noise_{t}->BP_{t}: identity()
19
20# interactions temporal recursive to temporal recursive
21# 1) update of same node from previous timestep
22BP_{t-1}->BP_{t}: identity()
23Drug_{t-1}->Drug_{t}: identity()
24# 2) update of another node from same timestep
25BP_{t}->Drug_{t}: identity()
26# 3) update of another node from previous timesteps
27Drug_{t-2}->BP_{t}: identity()

Note, how the Age node lacks the _{t} subscript; it is considered a baseline variable (BLV) that is created at the beginning and does not change. Additionally, we make a distinction between temporal recursive and temporal non-recursive variables. Both BP and Drug belong to the former class because they depend on nodes from previous timesteps. On the other hand, Noise is not conditioned on a previous inputs and thus is considered temporal non-recursive. In general, variables can exhibit long-range dependencies that go further back than just the previous timestep. Therefore, we add another temporal dependency from Drug to BP, where the effect is delayed by two timesteps.

For all temporal variables the first timestep of the simulation is indexed as 1. All temporal recursive definitions require a set of initial values that have to be specified. In the case of Drug, for example, one has to specify the values for Drug_-1 and Drug_0 because we reference Drug_-1 in the definition of BP_1.

Parsing and Calling Temporal Outlines

To actually parse your temporal outline, all you need to do is to instantiate the TemporalDescription class with a temporal outline and a n_timesteps argument.

 1from pyparcs.temporal import TemporalDescription
 2from pyparcs import Graph
 3import numpy as np
 4np.random.seed(42)
 5
 6description = TemporalDescription('temporal_graph_description.yml', n_timesteps=3)
 7graph = Graph(description)
 8
 9samples, _ = graph.sample(size=5)
10print(samples)
11#          Age       BP_0  Drug_0  Drug_neg1  ...  Drug_1       BP_3  Drug_2  Drug_3
12# 0   4.023505   9.160127     0.0        0.0  ...     1.0   4.571410     1.0     1.0
13# 1  15.981643  10.016958     0.0        0.0  ...     1.0  10.498443     1.0     1.0
14# 2   0.496344  10.670488     0.0        0.0  ...     1.0  12.019636     1.0     1.0
15# 3  12.139462   9.377000     0.0        0.0  ...     1.0   9.942749     1.0     1.0
16# 4  11.713087   9.016991     0.0        0.0  ...     1.0   7.224285     1.0     1.0
17#
18# [5 rows x 13 columns]

Deterministic Nodes in Temporal Graphs

As introduced in this section, deterministic nodes are declared by a custom user-defined python function. What if the deterministic node is temporal? e.g. instead of having a function like \(C = A^2 + B\), we have \(C_t = A_{t-2}^2 + B_{t}\)

Writing the custom function for a deterministic node is almost the same as writing a static function, with a small modification. Below is the temporal description file:

 1# nodes
 2A: uniform(mu_=1, diff_=2)
 3B_{t}: normal(mu_=B_{t-1}, sigma_=1)
 4C_{t}: deterministic(customs.py, temporal_custom_func)
 5
 6# initial values
 7B_{-1}: constant(0)
 8B_{0}: constant(2)
 9C_{0}: constant(1)
10
11# edges
12A->B_{t}: identity()
13B_{t-1}->B_{t}: identity()
14A->C_{t}: identity()
15B_{t-2}->C_{t}: identity()
16C_{t-1}->C_{t}: identity()

Firstly, the data columns in the custom function is just as we must expect it, e.g., data['A_{t-1}'] or data['B_{t}']. Secondly, you must use the temporal decorator for your custom function. An example is depicted below:

1from pyparcs.temporal import temporal_deterministic
2
3
4@temporal_deterministic(['B', 'C'], 't-2')
5def temporal_custom_func(data):
6    return data['A'] + data['B_{t-2}'] + data['C_{t-1}']

In this code, the temporal_deterministic decorator receives two inputs:

  • list of all temporal nodes in the function;

  • the earliest time index that appears in the function.

According to this code, the nodes C_1, C_2, C_3 will be calculated as

\[\begin{split}C_1 &= A+B_{-1}+C_{0}, \\ C_2 &= A+B_{0}+C_{1}, \\ C_3 &= A+B_{1}+C_{2}.\end{split}\]

Finally, here is the PARCS main code:

 1from pyparcs import Graph
 2from pyparcs.temporal import TemporalDescription
 3import numpy as np
 4np.random.seed(42)
 5
 6description = TemporalDescription('description.yml', n_timesteps=3)
 7graph = Graph(description)
 8samples, _ = graph.sample(size=5)
 9
10print(samples[['A', 'B_1', 'B_2', 'B_3', 'C_1', 'C_2', 'C_3']])
11#           A       B_1       B_2       B_3       C_1       C_2       C_3
12# 0  0.749080  2.618855  2.868731  1.857774  1.749080  4.498160  7.866095
13# 1  0.041169  2.963863  2.165531  1.257099  1.041169  3.082338  6.087370
14# 2  1.223706  1.452870  1.111365  1.001025  2.223706  5.447412  8.123987
15# 3  1.215090  0.486305  2.120447  3.940599  2.215090  5.430179  7.131574
16# 4  0.244076  0.180118  1.516700  0.869589  1.244076  3.488153  3.912348
17
18print((samples['C_1']).equals(samples['A'] + samples['B_neg1'] + samples['C_0']))
19print((samples['C_2']).equals(samples['A'] + samples['B_0'] + samples['C_1']))
20print((samples['C_3']).equals(samples['A'] + samples['B_1'] + samples['C_2']))
21# True
22# True
23# True