Theory IV: Cycles in the graph¶
Not all model collections are acyclic: models can, directly or indirectly through other models, depend on each other.
We’ll re-use the same Model1, Model2, Model3 as before, and add a cycle:
def Model4(model5_output, a, model1_output):
return model5_output + a + model1_output
def Model5(model1_output, b, model6_output):
return model1_output + b * model6_output
def Model6(d, model4_output):
return d + model4_output
These form a chain: Model4 requires Model5, which requires Model6, which again requires Model4.
There is now no order at all in which the models can be run one-by-one: the topological sort will simply error:
cycle_graph = models_to_graph([Model1, Model2, Model3, Model4, Model5, Model6])
try:
list(nx.topological_sort(cycle_graph))
except nx.NetworkXUnfeasible as e:
print("No valid ordering:", e)
No valid ordering: Graph contains a cycle or graph changed during iteration
Such cycles can be resolved with a fixed point iteration. In order to do this, we do need to know what exactly the cycle is. In this case, it’s straightforward, but it’s not always the case.
A standard graph algorithm that finds such cycles is the strongly connected component algorithm.
Instead of running model-by-model, this forces us to run ‘block’ by block. A block is a model or a set of models that are run together: in the case of a cycle, it’s because they have to be solved together.
If you have an acyclic graph, each model is itself just a block, but not here:
def blocks_in_order(nx_digraph):
"""The models grouped into blocks, and the blocks put in a valid run order."""
blocks = list(nx.strongly_connected_components(nx_digraph))
block_of = {model: i for i, block in enumerate(blocks) for model in block}
# Order each block:
block_graph = nx.DiGraph()
block_graph.add_nodes_from(range(len(blocks)))
block_graph.add_edges_from((block_of[producer], block_of[consumer])
for producer, consumer in nx_digraph.edges
if block_of[producer] != block_of[consumer])
return [sorted(blocks[i]) for i in nx.topological_sort(block_graph)]
blocks_in_order(cycle_graph)
[['Model1'], ['Model2'], ['Model4', 'Model5', 'Model6'], ['Model3']]
We see that we can run Model1 and Model2 first, then have to solve the block cycle, and then run Model3 (which could also be run before the cycle).
Solving a block¶
Running a single model is unchanged from the previous notebook:
def run_model(model, data_structure):
# Get I/O
interface_i = interface(model)[model.__name__]
# Prepare inputs:
inputs = {input_name: data_structure[input_name] for input_name in interface_i["inputs"]}
# Return updated datastructure:
return {**data_structure, interface_i["outputs"][0]: model(**inputs)}
A block of several models cannot be run like that: whichever model we start with is missing one of its inputs. The way out is to assume a value for one of the variables inside the cycle [1] and make that variable the iteration variable.
Given that assumption, in a block, that model output will no longer be needed and thus it can now be ordered:
def block_order(block, nx_digraph, assumed):
"""The order to run a block's models in, once `assumed` is taken as given."""
block_graph = nx_digraph.subgraph(block).copy()
# Taking `assumed` as given means the edges carrying it are no longer dependencies.
block_graph.remove_edges_from([(producer, consumer)
for producer, consumer, edge in block_graph.edges(data=True)
if assumed in edge["inputs"]])
return list(nx.topological_sort(block_graph))
cycle = ['Model4', 'Model5', 'Model6']
for assumed in ('model4_output', 'model5_output', 'model6_output'):
print(f"assuming {assumed:15s} ->", block_order(cycle, cycle_graph, assumed))
assuming model4_output -> ['Model6', 'Model5', 'Model4']
assuming model5_output -> ['Model4', 'Model6', 'Model5']
assuming model6_output -> ['Model5', 'Model4', 'Model6']
Each choice gives a different (valid) order. Now the block can be run: assume a value, run the models in that order, and see what comes back out. Convergence is achieved when the resulting value is the same as the assumed value:
def solve_block(block, data_structure, models, nx_digraph, assumed,
start = 1.0, tolerance = 1e-10, max_iterations = 100):
by_name = {model.__name__: model for model in models}
order = block_order(block, nx_digraph, assumed)
# We have to start somewhere: nothing has produced `assumed` yet.
ds = {**data_structure, assumed: start}
for iteration in range(max_iterations):
old_value = ds[assumed]
for model_name in order:
ds = run_model(by_name[model_name], ds)
# Converged when running the block reproduces the value we assumed.
if abs(ds[assumed] - old_value) < tolerance:
print(f"{block}: converged in {iteration + 1} iterations, assuming '{assumed}'")
return ds
print(f"{block}: did NOT converge in {max_iterations} iterations, assuming '{assumed}'")
return ds
And that is enough to run any collection of models, cyclic or not:
def TotalModel(data_structure, models : set, assumed):
ds = data_structure.copy()
if not set(external_inputs(models)) == set(ds.keys()):
raise ValueError(f"The provided data structure keys {set(ds.keys())} " +
f"do not match the required external inputs {set(external_inputs(models))} for the given models.")
nx_digraph = models_to_graph(models)
by_name = {model.__name__: model for model in models}
for block in blocks_in_order(nx_digraph):
if len(block) == 1:
# An ordinary model: everything it reads is available already.
ds = run_model(by_name[block[0]], ds)
else:
# A cycle: it has to be solved. (note we start with a value of 1)
ds = solve_block(block, ds, models, nx_digraph, assumed)
return ds
all_models = {Model1, Model2, Model3, Model4, Model5, Model6}
ds_start = {"a": 1.0, "b": 0.5, "c": 2.0, "d": 0.1, "e": 0.2}
TotalModel(ds_start, all_models, assumed = "model5_output")
['Model4', 'Model5', 'Model6']: converged in 35 iterations, assuming 'model5_output'
{'a': 1.0,
'b': 0.5,
'c': 2.0,
'd': 0.1,
'e': 0.2,
'model1_output': 0.8075731482210419,
'model2_output': 9.901012037547295,
'model5_output': 3.5227194445897045,
'model4_output': 5.330292592737326,
'model6_output': 5.430292592737326,
'model3_output': 1.9113901980206618}
This works, and the nice thing is it works for any model collection like this! You just have to assume a model output and then the fixed point iteration will handle it.
However, there were two big hidden assumptions:
We chose which variable is the iteration variable. All three would have worked, so why this particular one?
TotalModel(ds_start, all_models, assumed = "model4_output")
['Model4', 'Model5', 'Model6']: converged in 36 iterations, assuming 'model4_output'
{'a': 1.0,
'b': 0.5,
'c': 2.0,
'd': 0.1,
'e': 0.2,
'model1_output': 0.8075731482210419,
'model2_output': 9.901012037547295,
'model4_output': 5.330292592821154,
'model6_output': 5.430292592758139,
'model5_output': 3.5227194446001113,
'model3_output': 1.9113901980206618}
TotalModel(ds_start, all_models, assumed = "model6_output")
['Model4', 'Model5', 'Model6']: converged in 36 iterations, assuming 'model6_output'
{'a': 1.0,
'b': 0.5,
'c': 2.0,
'd': 0.1,
'e': 0.2,
'model1_output': 0.8075731482210419,
'model2_output': 9.901012037547295,
'model6_output': 5.430292592819699,
'model5_output': 3.5227194445986565,
'model4_output': 5.330292592819699,
'model3_output': 1.9113901980206618}
The algorithm: why should we use a fixed point iteration always? In some cases this might not converge, and we don’t know whether the failure is algorithmic or whether the problem really has no answer: [2]
TotalModel({**ds_start, "b": 1.1}, all_models, assumed = "model5_output")
['Model4', 'Model5', 'Model6']: did NOT converge in 100 iterations, assuming 'model5_output'
{'a': 1.0,
'b': 1.1,
'c': 2.0,
'd': 0.1,
'e': 0.2,
'model1_output': 1.2190357525781628,
'model2_output': 12.78125026804714,
'model5_output': 533268.563728798,
'model4_output': 484788.39517549577,
'model6_output': 484788.49517549574,
'model3_output': 1.911390209140226}
These choices cannot be made just on the basis of the graph (i.e. algorithmically); there’s always some ambiguity.
And there are more choices still: whether two cycles that happen to sit next to each other should be one block or two, or whether the cycle should not be solved here at all, but handed to an optimizer as a consistency constraint.
Summary¶
A cyclic graph has no valid model ordering, but it does have a valid block ordering: the strongly connected components, ordered by sorting a graph of the blocks themselves. A block of one model is run; a block of several is a cycle and has to be solved. The graph-based representation enables us to look at the minimal cycles and automatically assign a solver (in this notebook a fixed point iteration) to it.
Solving one requires assuming a variable inside it, which cuts the cycle and makes the block orderable again. Unlike the ordering itself, that choice (and the choice of solver, and of what is solved together) cannot be read off the graph.
In other words, we need to, as users, take a look at the graph and make an informed decision.
In the next notebook, we’ll look at how to represent the graph in an easier-to-understand way, and look at other possible cycles.