diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 9d27ef6..27f5125 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -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 diff --git a/GSM_Diagram.png b/GSM_Diagram.png new file mode 100644 index 0000000..3072d7e Binary files /dev/null and b/GSM_Diagram.png differ diff --git a/Graph_State_Machine/Util/misc.py b/Graph_State_Machine/Util/misc.py index 4e6b6a9..76bd0f7 100644 --- a/Graph_State_Machine/Util/misc.py +++ b/Graph_State_Machine/Util/misc.py @@ -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') @@ -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}; " diff --git a/Graph_State_Machine/__init__.py b/Graph_State_Machine/__init__.py index ca92988..17c9b75 100644 --- a/Graph_State_Machine/__init__.py +++ b/Graph_State_Machine/__init__.py @@ -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 ' # __all__ = ['Graph', 'GSM'] diff --git a/Graph_State_Machine/graph.py b/Graph_State_Machine/graph.py index ec9dfd3..895951a 100644 --- a/Graph_State_Machine/graph.py +++ b/Graph_State_Machine/graph.py @@ -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 diff --git a/Graph_State_Machine/gsm.py b/Graph_State_Machine/gsm.py index 886e91f..997f334 100644 --- a/Graph_State_Machine/gsm.py +++ b/Graph_State_Machine/gsm.py @@ -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) @@ -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)] @@ -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): @@ -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] @@ -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 diff --git a/Graph_State_Machine/updaters.py b/Graph_State_Machine/updaters.py index 66eb635..f6b151a 100644 --- a/Graph_State_Machine/updaters.py +++ b/Graph_State_Machine/updaters.py @@ -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 + + diff --git a/README.rst b/README.rst index 6232122..5a36df4 100644 --- a/README.rst +++ b/README.rst @@ -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. @@ -219,10 +227,10 @@ 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']), @@ -230,10 +238,10 @@ A small GSM which selects the appropriate R linear regression function and distr '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'] }) diff --git a/setup.py b/setup.py index 5ea3978..c46ebfb 100644 --- a/setup.py +++ b/setup.py @@ -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', @@ -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' ] )