Skip to content

Commit

Permalink
Version bump to 3.2 (mostly for last commit).
Browse files Browse the repository at this point in the history
Improved optional warning suppression for graph topology features and lossy shorthand reversal.
Added a greedy_updater field to GSMs and a greedy flag for steps to add all candidates to state.
  • Loading branch information
T-Flet committed Aug 26, 2023
1 parent 230173f commit d6496ec
Show file tree
Hide file tree
Showing 9 changed files with 46 additions and 23 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
max-parallel: 4
matrix:
os: [windows-latest, ubuntu-latest, macOS-latest]
python-version: [3.8]
python-version: ["3.10"]

steps:
- uses: actions/checkout@v1
Expand Down
Binary file added GSM_Diagram.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions Graph_State_Machine/Util/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def check_edge_dict_keys(dict_many: Dict[str, List[str]]) -> None:
assert not (bad_keys := [k for k in dict_many.keys() if k not in ok_keys]), f'The only keys allowed in graph-shortand edge dictionaries are {ok_keys}; the following bad keys were provided: {bad_keys}'


def reverse_adjacencies(one_type_graph: Dict[str, List[str]], allow_losing_singletons = False, allow_losing_joint_sufficiency = False) -> Dict[str, List[str]]:
def reverse_adjacencies(one_type_graph: Dict[str, List[str]], allow_losing_singletons = False, allow_losing_joint_sufficiency = False, suppress_warnings = False) -> Dict[str, List[str]]:
'''Invert a dictionary of adjacencies, possibly losing singletons, e.g. {A: [B, C], D: []} -> {B: [A], C: [A]}'''
if not allow_losing_singletons: assert not (singletons := [k for k, v in one_type_graph.items() if not v]), f'Singletons {singletons} loss prevented in reverse_adjacencies; set allow_losing_singletons to True to allow it (but check carefully first)'
opposite = dict(are_necessary = 'necessary_for', are_sufficient = 'sufficient_for', necessary_for = 'are_necessary', sufficient_for = 'are_sufficient', plain = 'plain')
Expand All @@ -29,8 +29,8 @@ def reverse_adjacencies(one_type_graph: Dict[str, List[str]], allow_losing_singl
for edge_type, end in [(t, e) for t, es in ends.items() for e in es]:
if isinstance(end, list):
if allow_losing_joint_sufficiency:
warn(f"Allowing lossy reversal of Node sub-list {end} in adjacency key '{edge_type}'; i.e. these edges will be plain when reversed; "
f"if these are intended joint sufficiencies, ensure they are declared on the '{start}' side; set allow_losing_joint_sufficiency to False to prevent this")
if not suppress_warnings: warn(f"Allowing lossy reversal of Node sub-list {end} in adjacency key '{edge_type}'; i.e. these edges will be plain when reversed; "
f"if these are intended joint sufficiencies, ensure they are declared on the '{start}' side; set allow_losing_joint_sufficiency to False to prevent this")
pairs += [('plain', e) for e in end]
else: raise ValueError(f"Within reverse_adjacencies, all the values within adjacency dictionaries need to be simple lists of Nodes (normally 'are_sufficient' is allowed sub-lists for joint sufficiency); "
f"instead, '{start}''s '{edge_type}' contained the list {end}; "
Expand Down
2 changes: 1 addition & 1 deletion Graph_State_Machine/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Graph-State-Machine - A simple library to build easily interpretable computational constructs similar to a Turing machine over a graph, where states are combinations of a graph's (typed) nodes; an example use would be a transparent backend logic which navigates an ontology"""

__version__ = '3.1' # Change it in setup.py too
__version__ = '3.2' # Change it in setup.py too
__author__ = 'Thomas Fletcher <[email protected]>'
# __all__ = ['Graph', 'GSM']

Expand Down
4 changes: 2 additions & 2 deletions Graph_State_Machine/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,10 @@ def group_nodes(self, nodes: List[Node] = None) -> Dict[NodeType, List[Node]]:
unsorted = group_by(lambda n: self.nodes_to_types[n], nodes if nodes else self.G.nodes())
return {nt: unsorted[nt] for nt in sorted(unsorted)}

def extend_with(self, extension_graph):
def extend_with(self, extension_graph, warn_about_problematic_sufficiencies = True):
'''Note: returns a new object; does not affect the original'''
res = deepcopy(self)
return res._set_graph(nx.compose(res.G, extension_graph.G))
return res._set_graph(nx.compose(res.G, extension_graph.G), warn_about_problematic_sufficiencies)


# Utility methods
Expand Down
25 changes: 14 additions & 11 deletions Graph_State_Machine/gsm.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@

from Graph_State_Machine.selectors import identity
from Graph_State_Machine.scanners import by_score
from Graph_State_Machine.updaters import list_accumulator
from Graph_State_Machine.updaters import list_accumulator, list_accumulator_greedy
from Graph_State_Machine.types import *
from Graph_State_Machine.Util.misc import expand_user_warning


class GSM:
def __init__(self, graph: Graph, state: State = [],
node_scanner: Scanner = by_score(), state_updater: Updater = list_accumulator, selector = identity):
node_scanner: Scanner = by_score(), state_updater: Updater = list_accumulator, selector = identity,
greedy_state_updater: Updater = list_accumulator_greedy):
'''Define a Graph State Machine by providing the starting graph and state and the two operation functions:
- the scanner, which assigns scores to nodes of interest given the state nodes (e.g. their neighbours)
- the updater, which updates the state based on the scanner's output; it can update the graph too (though it does not have to)
Expand All @@ -26,6 +27,7 @@ def __init__(self, graph: Graph, state: State = [],
self.state = state
self.selector = selector
self.updater = state_updater
self.greedy_updater = greedy_state_updater

self.log = [dict(method = '__init__', graph = graph, state = state, node_scanner = node_scanner,
state_updater = state_updater, list_accumulator = list_accumulator, selector = selector)]
Expand All @@ -35,21 +37,22 @@ def __str__(self): return f'GSM State: {self.state.__str__()}'

# Core functionality methods

def extend_with(self, extension_graph):
def extend_with(self, extension_graph, warn_about_problematic_sufficiencies = True):
'''Note: returns a new object; does not affect the original'''
res = deepcopy(self)
res.graph = res.graph.extend_with(extension_graph)
res.graph = res.graph.extend_with(extension_graph, warn_about_problematic_sufficiencies)
return res

def _scan(self, *args, **kwargs) -> List[Tuple[Node, Any]]:
'''Note: this method just returns the step result; it does not update the state'''
return self.scanner(self.graph, self.selector(self.state), *args, **kwargs)

def step(self, *args, conditional = False, **kwargs):
def step(self, *args, conditional = False, greedy = False, **kwargs):
'''Scan nodes of interest and perform a step (i.e. have the step_handler update the state by processing the scan result).
If conditional == True, the step is performed only if no node of the requested type is in state.
In that case an ASSUMPTION is made:
that the first (named or unnamed) argument of scanner (after graph and state) is a singleton list of the type of node to look for'''
that the first (named or unnamed) argument of scanner (after graph and state) is a singleton list of the type of node to look for.
If greedy == True, then the step uses the self.greedy_updater updater (THE DEFAULT ONE IS ONLY FOR LIST-TYPE STATES), adding ALL candidates to state.'''
if args and kwargs: raise TypeError('Step function arguments should be either all named or all unnamed (except for "conditional", which should always be named)')
node_type = (args if args else (list(kwargs.values())))[0][0]
if conditional and self.type_in_state(node_type):
Expand All @@ -59,17 +62,17 @@ def step(self, *args, conditional = False, **kwargs):
def f():
scan_result = self._scan(*args, **kwargs)
self.log.append(dict(method = 'step', scan_result = scan_result, scanner_arguments = self._ensure_scanner_args_are_named(args, kwargs)))
self.state, self.graph = self.updater(self.state, self.graph, scan_result)
self.state, self.graph = (self.greedy_updater if greedy else self.updater)(self.state, self.graph, scan_result)
expand_user_warning(f, lambda: f'; last log entry: {self.log[-1]}')
return self

def consecutive_steps(self, *scanners_arguments: List[Union[List, Dict]], conditional = False):
def consecutive_steps(self, *scanners_arguments: List[Union[List, Dict]], conditional = False, greedy = False):
'''Perform steps of the given node types one after the other, i.e. using the progressively updated state for each new step.
Note: steps can be made either all standard or all conditional.'''
for ss in scanners_arguments: self.step(**self._ensure_scanner_args_are_named(ss), conditional = conditional)
for ss in scanners_arguments: self.step(**self._ensure_scanner_args_are_named(ss), conditional = conditional, greedy = greedy)
return self

def parallel_steps(self, *scanners_arguments: List[Union[List, Dict]], conditional = False):
def parallel_steps(self, *scanners_arguments: List[Union[List, Dict]], conditional = False, greedy = False):
'''Perform steps of the given node types all starting from the same state, i.e. only apply state updates after scan results are known.
Note: steps can be made either all standard or all conditional.'''
scanners_arguments = [self._ensure_scanner_args_are_named(ss) for ss in scanners_arguments]
Expand All @@ -80,7 +83,7 @@ def parallel_steps(self, *scanners_arguments: List[Union[List, Dict]], condition
warnings.warn(f'Step of type \'{node_type}\' not taken because nodes of that type were already in state')
return self
else:
def f(): self.state, self.graph = self.updater(self.state, self.graph, rs)
def f(): self.state, self.graph = (self.greedy_updater if greedy else self.updater)(self.state, self.graph, rs)
expand_user_warning(f, lambda: f'; (parallel) step arguments: {ss}')
self.log.append(dict(method = 'parallel_steps', scan_results = scan_results, scanners_arguments = scanners_arguments))
return self
Expand Down
8 changes: 8 additions & 0 deletions Graph_State_Machine/updaters.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,11 @@ def dict_accumulator_closure(state: State, graph: Graph, scan_result: ScanResult
return dict_accumulator_closure


def list_accumulator_greedy(state: State, graph: Graph, scan_result: ScanResult) -> Tuple[State, Graph]:
'''Never removes from state and adds ALL NODES from step_result to a simple-list state'''
if scan_result: return state + scan_result[0], graph
else:
warn('A Scanner returned no result: no appropriate candidates identified')
return state, graph


16 changes: 12 additions & 4 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ Given a graph with typed nodes and a state object from which a list of nodes can
:code:`Updater`
A function to process the scan result and thus update the state and possibly the graph itself


.. figure:: GSM_Diagram.png
:align: center
:figclass: align-center

Schematic representation of a GSM step (with data flow in dashed arrows)


This computational construct is different from a finite state machine on a graph and from a
graph cellular automaton, but it shares some similarities with both in that it generalises some of
their features for the benefit of human ease of design and readability.
Expand Down Expand Up @@ -219,21 +227,21 @@ A small GSM which selects the appropriate R linear regression function and distr
'Distribution': {
'Normal': ['stan_glm', 'glm', 'gaussian'],
'Binomial': ['stan_glm', 'glm', 'binomial'],
'Multinomial': ['stan_polr', 'polr_tolerant', 'multinom'],
'Categorical': ['stan_polr', 'polr_tolerant', 'multinom'],
'Poisson': ['stan_glm', 'glm', 'poisson'],
'Beta': ['stan_betareg', 'betareg'],
'gamma': ['stan_glm', 'glm', 'Gamma'],
'Gamma_': ['stan_glm', 'glm', 'Gamma'],
'Inverse Gaussian': ['stan_glm', 'glm', 'inverse.gaussian']
},
'Family Implementation': strs_as_keys(['binomial', 'poisson', 'Gamma', 'gaussian', 'inverse.gaussian']),
'Methodology Function': strs_as_keys(['glm', 'betareg', 'polr_tolerant', 'multinom', 'stan_glm', 'stan_betareg', 'stan_polr']),
'Data Feature': reverse_adjacencies({ # Reverse-direction definition here since more readable i.e. defining the contents of the lists
'Binomial': ['Binary', 'Integer', '[0,1]', 'Boolean'],
'Poisson': ['Non-Negative', 'Integer', 'Consecutive', 'Counts-Like'],
'Multinomial': ['Factor', 'Consecutive', 'Non-Negative', 'Integer'],
'Categorical': ['Factor', 'Consecutive', 'Non-Negative', 'Integer'],
'Normal': ['Integer', 'Real', '+ and -'],
'Beta': ['Real', '[0,1]'],
'gamma': ['Non-Negative', 'Integer', 'Real', 'Non-Zero'],
'Gamma_': ['Non-Negative', 'Integer', 'Real', 'Non-Zero'],
'Inverse Gaussian': ['Non-Negative', 'Integer', 'Real', 'Non-Zero'],
'polr_tolerant': ['Consecutive']
})
Expand Down
6 changes: 5 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def read_requirements():

setup(
name = 'Graph-State-Machine',
version = '3.1', # Update in package __init__ too
version = '3.2', # Update in package __init__ too
url = 'https://github.com/T-Flet/Graph-State-Machine',
license = 'BSD 3-Clause',

Expand All @@ -37,5 +37,9 @@ def read_requirements():
'Development Status :: 5 - Production/Stable',
'Programming Language :: Python',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12'
]
)

0 comments on commit d6496ec

Please sign in to comment.