ismags.py 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169
  1. """
  2. ISMAGS Algorithm
  3. ================
  4. Provides a Python implementation of the ISMAGS algorithm. [1]_
  5. It is capable of finding (subgraph) isomorphisms between two graphs, taking the
  6. symmetry of the subgraph into account. In most cases the VF2 algorithm is
  7. faster (at least on small graphs) than this implementation, but in some cases
  8. there is an exponential number of isomorphisms that are symmetrically
  9. equivalent. In that case, the ISMAGS algorithm will provide only one solution
  10. per symmetry group.
  11. >>> petersen = nx.petersen_graph()
  12. >>> ismags = nx.isomorphism.ISMAGS(petersen, petersen)
  13. >>> isomorphisms = list(ismags.isomorphisms_iter(symmetry=False))
  14. >>> len(isomorphisms)
  15. 120
  16. >>> isomorphisms = list(ismags.isomorphisms_iter(symmetry=True))
  17. >>> answer = [{0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9}]
  18. >>> answer == isomorphisms
  19. True
  20. In addition, this implementation also provides an interface to find the
  21. largest common induced subgraph [2]_ between any two graphs, again taking
  22. symmetry into account. Given `graph` and `subgraph` the algorithm will remove
  23. nodes from the `subgraph` until `subgraph` is isomorphic to a subgraph of
  24. `graph`. Since only the symmetry of `subgraph` is taken into account it is
  25. worth thinking about how you provide your graphs:
  26. >>> graph1 = nx.path_graph(4)
  27. >>> graph2 = nx.star_graph(3)
  28. >>> ismags = nx.isomorphism.ISMAGS(graph1, graph2)
  29. >>> ismags.is_isomorphic()
  30. False
  31. >>> largest_common_subgraph = list(ismags.largest_common_subgraph())
  32. >>> answer = [{1: 0, 0: 1, 2: 2}, {2: 0, 1: 1, 3: 2}]
  33. >>> answer == largest_common_subgraph
  34. True
  35. >>> ismags2 = nx.isomorphism.ISMAGS(graph2, graph1)
  36. >>> largest_common_subgraph = list(ismags2.largest_common_subgraph())
  37. >>> answer = [
  38. ... {1: 0, 0: 1, 2: 2},
  39. ... {1: 0, 0: 1, 3: 2},
  40. ... {2: 0, 0: 1, 1: 2},
  41. ... {2: 0, 0: 1, 3: 2},
  42. ... {3: 0, 0: 1, 1: 2},
  43. ... {3: 0, 0: 1, 2: 2},
  44. ... ]
  45. >>> answer == largest_common_subgraph
  46. True
  47. However, when not taking symmetry into account, it doesn't matter:
  48. >>> largest_common_subgraph = list(ismags.largest_common_subgraph(symmetry=False))
  49. >>> answer = [
  50. ... {1: 0, 0: 1, 2: 2},
  51. ... {1: 0, 2: 1, 0: 2},
  52. ... {2: 0, 1: 1, 3: 2},
  53. ... {2: 0, 3: 1, 1: 2},
  54. ... {1: 0, 0: 1, 2: 3},
  55. ... {1: 0, 2: 1, 0: 3},
  56. ... {2: 0, 1: 1, 3: 3},
  57. ... {2: 0, 3: 1, 1: 3},
  58. ... {1: 0, 0: 2, 2: 3},
  59. ... {1: 0, 2: 2, 0: 3},
  60. ... {2: 0, 1: 2, 3: 3},
  61. ... {2: 0, 3: 2, 1: 3},
  62. ... ]
  63. >>> answer == largest_common_subgraph
  64. True
  65. >>> largest_common_subgraph = list(ismags2.largest_common_subgraph(symmetry=False))
  66. >>> answer = [
  67. ... {1: 0, 0: 1, 2: 2},
  68. ... {1: 0, 0: 1, 3: 2},
  69. ... {2: 0, 0: 1, 1: 2},
  70. ... {2: 0, 0: 1, 3: 2},
  71. ... {3: 0, 0: 1, 1: 2},
  72. ... {3: 0, 0: 1, 2: 2},
  73. ... {1: 1, 0: 2, 2: 3},
  74. ... {1: 1, 0: 2, 3: 3},
  75. ... {2: 1, 0: 2, 1: 3},
  76. ... {2: 1, 0: 2, 3: 3},
  77. ... {3: 1, 0: 2, 1: 3},
  78. ... {3: 1, 0: 2, 2: 3},
  79. ... ]
  80. >>> answer == largest_common_subgraph
  81. True
  82. Notes
  83. -----
  84. - The current implementation works for undirected graphs only. The algorithm
  85. in general should work for directed graphs as well though.
  86. - Node keys for both provided graphs need to be fully orderable as well as
  87. hashable.
  88. - Node and edge equality is assumed to be transitive: if A is equal to B, and
  89. B is equal to C, then A is equal to C.
  90. References
  91. ----------
  92. .. [1] M. Houbraken, S. Demeyer, T. Michoel, P. Audenaert, D. Colle,
  93. M. Pickavet, "The Index-Based Subgraph Matching Algorithm with General
  94. Symmetries (ISMAGS): Exploiting Symmetry for Faster Subgraph
  95. Enumeration", PLoS One 9(5): e97896, 2014.
  96. https://doi.org/10.1371/journal.pone.0097896
  97. .. [2] https://en.wikipedia.org/wiki/Maximum_common_induced_subgraph
  98. """
  99. __all__ = ["ISMAGS"]
  100. import itertools
  101. from collections import Counter, defaultdict
  102. from functools import reduce, wraps
  103. def are_all_equal(iterable):
  104. """
  105. Returns ``True`` if and only if all elements in `iterable` are equal; and
  106. ``False`` otherwise.
  107. Parameters
  108. ----------
  109. iterable: collections.abc.Iterable
  110. The container whose elements will be checked.
  111. Returns
  112. -------
  113. bool
  114. ``True`` iff all elements in `iterable` compare equal, ``False``
  115. otherwise.
  116. """
  117. try:
  118. shape = iterable.shape
  119. except AttributeError:
  120. pass
  121. else:
  122. if len(shape) > 1:
  123. message = "The function does not works on multidimensional arrays."
  124. raise NotImplementedError(message) from None
  125. iterator = iter(iterable)
  126. first = next(iterator, None)
  127. return all(item == first for item in iterator)
  128. def make_partitions(items, test):
  129. """
  130. Partitions items into sets based on the outcome of ``test(item1, item2)``.
  131. Pairs of items for which `test` returns `True` end up in the same set.
  132. Parameters
  133. ----------
  134. items : collections.abc.Iterable[collections.abc.Hashable]
  135. Items to partition
  136. test : collections.abc.Callable[collections.abc.Hashable, collections.abc.Hashable]
  137. A function that will be called with 2 arguments, taken from items.
  138. Should return `True` if those 2 items need to end up in the same
  139. partition, and `False` otherwise.
  140. Returns
  141. -------
  142. list[set]
  143. A list of sets, with each set containing part of the items in `items`,
  144. such that ``all(test(*pair) for pair in itertools.combinations(set, 2))
  145. == True``
  146. Notes
  147. -----
  148. The function `test` is assumed to be transitive: if ``test(a, b)`` and
  149. ``test(b, c)`` return ``True``, then ``test(a, c)`` must also be ``True``.
  150. """
  151. partitions = []
  152. for item in items:
  153. for partition in partitions:
  154. p_item = next(iter(partition))
  155. if test(item, p_item):
  156. partition.add(item)
  157. break
  158. else: # No break
  159. partitions.append({item})
  160. return partitions
  161. def partition_to_color(partitions):
  162. """
  163. Creates a dictionary that maps each item in each partition to the index of
  164. the partition to which it belongs.
  165. Parameters
  166. ----------
  167. partitions: collections.abc.Sequence[collections.abc.Iterable]
  168. As returned by :func:`make_partitions`.
  169. Returns
  170. -------
  171. dict
  172. """
  173. colors = {}
  174. for color, keys in enumerate(partitions):
  175. for key in keys:
  176. colors[key] = color
  177. return colors
  178. def intersect(collection_of_sets):
  179. """
  180. Given an collection of sets, returns the intersection of those sets.
  181. Parameters
  182. ----------
  183. collection_of_sets: collections.abc.Collection[set]
  184. A collection of sets.
  185. Returns
  186. -------
  187. set
  188. An intersection of all sets in `collection_of_sets`. Will have the same
  189. type as the item initially taken from `collection_of_sets`.
  190. """
  191. collection_of_sets = list(collection_of_sets)
  192. first = collection_of_sets.pop()
  193. out = reduce(set.intersection, collection_of_sets, set(first))
  194. return type(first)(out)
  195. class ISMAGS:
  196. """
  197. Implements the ISMAGS subgraph matching algorithm. [1]_ ISMAGS stands for
  198. "Index-based Subgraph Matching Algorithm with General Symmetries". As the
  199. name implies, it is symmetry aware and will only generate non-symmetric
  200. isomorphisms.
  201. Notes
  202. -----
  203. The implementation imposes additional conditions compared to the VF2
  204. algorithm on the graphs provided and the comparison functions
  205. (:attr:`node_equality` and :attr:`edge_equality`):
  206. - Node keys in both graphs must be orderable as well as hashable.
  207. - Equality must be transitive: if A is equal to B, and B is equal to C,
  208. then A must be equal to C.
  209. Attributes
  210. ----------
  211. graph: networkx.Graph
  212. subgraph: networkx.Graph
  213. node_equality: collections.abc.Callable
  214. The function called to see if two nodes should be considered equal.
  215. It's signature looks like this:
  216. ``f(graph1: networkx.Graph, node1, graph2: networkx.Graph, node2) -> bool``.
  217. `node1` is a node in `graph1`, and `node2` a node in `graph2`.
  218. Constructed from the argument `node_match`.
  219. edge_equality: collections.abc.Callable
  220. The function called to see if two edges should be considered equal.
  221. It's signature looks like this:
  222. ``f(graph1: networkx.Graph, edge1, graph2: networkx.Graph, edge2) -> bool``.
  223. `edge1` is an edge in `graph1`, and `edge2` an edge in `graph2`.
  224. Constructed from the argument `edge_match`.
  225. References
  226. ----------
  227. .. [1] M. Houbraken, S. Demeyer, T. Michoel, P. Audenaert, D. Colle,
  228. M. Pickavet, "The Index-Based Subgraph Matching Algorithm with General
  229. Symmetries (ISMAGS): Exploiting Symmetry for Faster Subgraph
  230. Enumeration", PLoS One 9(5): e97896, 2014.
  231. https://doi.org/10.1371/journal.pone.0097896
  232. """
  233. def __init__(self, graph, subgraph, node_match=None, edge_match=None, cache=None):
  234. """
  235. Parameters
  236. ----------
  237. graph: networkx.Graph
  238. subgraph: networkx.Graph
  239. node_match: collections.abc.Callable or None
  240. Function used to determine whether two nodes are equivalent. Its
  241. signature should look like ``f(n1: dict, n2: dict) -> bool``, with
  242. `n1` and `n2` node property dicts. See also
  243. :func:`~networkx.algorithms.isomorphism.categorical_node_match` and
  244. friends.
  245. If `None`, all nodes are considered equal.
  246. edge_match: collections.abc.Callable or None
  247. Function used to determine whether two edges are equivalent. Its
  248. signature should look like ``f(e1: dict, e2: dict) -> bool``, with
  249. `e1` and `e2` edge property dicts. See also
  250. :func:`~networkx.algorithms.isomorphism.categorical_edge_match` and
  251. friends.
  252. If `None`, all edges are considered equal.
  253. cache: collections.abc.Mapping
  254. A cache used for caching graph symmetries.
  255. """
  256. # TODO: graph and subgraph setter methods that invalidate the caches.
  257. # TODO: allow for precomputed partitions and colors
  258. self.graph = graph
  259. self.subgraph = subgraph
  260. self._symmetry_cache = cache
  261. # Naming conventions are taken from the original paper. For your
  262. # sanity:
  263. # sg: subgraph
  264. # g: graph
  265. # e: edge(s)
  266. # n: node(s)
  267. # So: sgn means "subgraph nodes".
  268. self._sgn_partitions_ = None
  269. self._sge_partitions_ = None
  270. self._sgn_colors_ = None
  271. self._sge_colors_ = None
  272. self._gn_partitions_ = None
  273. self._ge_partitions_ = None
  274. self._gn_colors_ = None
  275. self._ge_colors_ = None
  276. self._node_compat_ = None
  277. self._edge_compat_ = None
  278. if node_match is None:
  279. self.node_equality = self._node_match_maker(lambda n1, n2: True)
  280. self._sgn_partitions_ = [set(self.subgraph.nodes)]
  281. self._gn_partitions_ = [set(self.graph.nodes)]
  282. self._node_compat_ = {0: 0}
  283. else:
  284. self.node_equality = self._node_match_maker(node_match)
  285. if edge_match is None:
  286. self.edge_equality = self._edge_match_maker(lambda e1, e2: True)
  287. self._sge_partitions_ = [set(self.subgraph.edges)]
  288. self._ge_partitions_ = [set(self.graph.edges)]
  289. self._edge_compat_ = {0: 0}
  290. else:
  291. self.edge_equality = self._edge_match_maker(edge_match)
  292. @property
  293. def _sgn_partitions(self):
  294. if self._sgn_partitions_ is None:
  295. def nodematch(node1, node2):
  296. return self.node_equality(self.subgraph, node1, self.subgraph, node2)
  297. self._sgn_partitions_ = make_partitions(self.subgraph.nodes, nodematch)
  298. return self._sgn_partitions_
  299. @property
  300. def _sge_partitions(self):
  301. if self._sge_partitions_ is None:
  302. def edgematch(edge1, edge2):
  303. return self.edge_equality(self.subgraph, edge1, self.subgraph, edge2)
  304. self._sge_partitions_ = make_partitions(self.subgraph.edges, edgematch)
  305. return self._sge_partitions_
  306. @property
  307. def _gn_partitions(self):
  308. if self._gn_partitions_ is None:
  309. def nodematch(node1, node2):
  310. return self.node_equality(self.graph, node1, self.graph, node2)
  311. self._gn_partitions_ = make_partitions(self.graph.nodes, nodematch)
  312. return self._gn_partitions_
  313. @property
  314. def _ge_partitions(self):
  315. if self._ge_partitions_ is None:
  316. def edgematch(edge1, edge2):
  317. return self.edge_equality(self.graph, edge1, self.graph, edge2)
  318. self._ge_partitions_ = make_partitions(self.graph.edges, edgematch)
  319. return self._ge_partitions_
  320. @property
  321. def _sgn_colors(self):
  322. if self._sgn_colors_ is None:
  323. self._sgn_colors_ = partition_to_color(self._sgn_partitions)
  324. return self._sgn_colors_
  325. @property
  326. def _sge_colors(self):
  327. if self._sge_colors_ is None:
  328. self._sge_colors_ = partition_to_color(self._sge_partitions)
  329. return self._sge_colors_
  330. @property
  331. def _gn_colors(self):
  332. if self._gn_colors_ is None:
  333. self._gn_colors_ = partition_to_color(self._gn_partitions)
  334. return self._gn_colors_
  335. @property
  336. def _ge_colors(self):
  337. if self._ge_colors_ is None:
  338. self._ge_colors_ = partition_to_color(self._ge_partitions)
  339. return self._ge_colors_
  340. @property
  341. def _node_compatibility(self):
  342. if self._node_compat_ is not None:
  343. return self._node_compat_
  344. self._node_compat_ = {}
  345. for sgn_part_color, gn_part_color in itertools.product(
  346. range(len(self._sgn_partitions)), range(len(self._gn_partitions))
  347. ):
  348. sgn = next(iter(self._sgn_partitions[sgn_part_color]))
  349. gn = next(iter(self._gn_partitions[gn_part_color]))
  350. if self.node_equality(self.subgraph, sgn, self.graph, gn):
  351. self._node_compat_[sgn_part_color] = gn_part_color
  352. return self._node_compat_
  353. @property
  354. def _edge_compatibility(self):
  355. if self._edge_compat_ is not None:
  356. return self._edge_compat_
  357. self._edge_compat_ = {}
  358. for sge_part_color, ge_part_color in itertools.product(
  359. range(len(self._sge_partitions)), range(len(self._ge_partitions))
  360. ):
  361. sge = next(iter(self._sge_partitions[sge_part_color]))
  362. ge = next(iter(self._ge_partitions[ge_part_color]))
  363. if self.edge_equality(self.subgraph, sge, self.graph, ge):
  364. self._edge_compat_[sge_part_color] = ge_part_color
  365. return self._edge_compat_
  366. @staticmethod
  367. def _node_match_maker(cmp):
  368. @wraps(cmp)
  369. def comparer(graph1, node1, graph2, node2):
  370. return cmp(graph1.nodes[node1], graph2.nodes[node2])
  371. return comparer
  372. @staticmethod
  373. def _edge_match_maker(cmp):
  374. @wraps(cmp)
  375. def comparer(graph1, edge1, graph2, edge2):
  376. return cmp(graph1.edges[edge1], graph2.edges[edge2])
  377. return comparer
  378. def find_isomorphisms(self, symmetry=True):
  379. """Find all subgraph isomorphisms between subgraph and graph
  380. Finds isomorphisms where :attr:`subgraph` <= :attr:`graph`.
  381. Parameters
  382. ----------
  383. symmetry: bool
  384. Whether symmetry should be taken into account. If False, found
  385. isomorphisms may be symmetrically equivalent.
  386. Yields
  387. ------
  388. dict
  389. The found isomorphism mappings of {graph_node: subgraph_node}.
  390. """
  391. # The networkx VF2 algorithm is slightly funny in when it yields an
  392. # empty dict and when not.
  393. if not self.subgraph:
  394. yield {}
  395. return
  396. elif not self.graph:
  397. return
  398. elif len(self.graph) < len(self.subgraph):
  399. return
  400. if symmetry:
  401. _, cosets = self.analyze_symmetry(
  402. self.subgraph, self._sgn_partitions, self._sge_colors
  403. )
  404. constraints = self._make_constraints(cosets)
  405. else:
  406. constraints = []
  407. candidates = self._find_nodecolor_candidates()
  408. la_candidates = self._get_lookahead_candidates()
  409. for sgn in self.subgraph:
  410. extra_candidates = la_candidates[sgn]
  411. if extra_candidates:
  412. candidates[sgn] = candidates[sgn] | {frozenset(extra_candidates)}
  413. if any(candidates.values()):
  414. start_sgn = min(candidates, key=lambda n: min(candidates[n], key=len))
  415. candidates[start_sgn] = (intersect(candidates[start_sgn]),)
  416. yield from self._map_nodes(start_sgn, candidates, constraints)
  417. else:
  418. return
  419. @staticmethod
  420. def _find_neighbor_color_count(graph, node, node_color, edge_color):
  421. """
  422. For `node` in `graph`, count the number of edges of a specific color
  423. it has to nodes of a specific color.
  424. """
  425. counts = Counter()
  426. neighbors = graph[node]
  427. for neighbor in neighbors:
  428. n_color = node_color[neighbor]
  429. if (node, neighbor) in edge_color:
  430. e_color = edge_color[node, neighbor]
  431. else:
  432. e_color = edge_color[neighbor, node]
  433. counts[e_color, n_color] += 1
  434. return counts
  435. def _get_lookahead_candidates(self):
  436. """
  437. Returns a mapping of {subgraph node: collection of graph nodes} for
  438. which the graph nodes are feasible candidates for the subgraph node, as
  439. determined by looking ahead one edge.
  440. """
  441. g_counts = {}
  442. for gn in self.graph:
  443. g_counts[gn] = self._find_neighbor_color_count(
  444. self.graph, gn, self._gn_colors, self._ge_colors
  445. )
  446. candidates = defaultdict(set)
  447. for sgn in self.subgraph:
  448. sg_count = self._find_neighbor_color_count(
  449. self.subgraph, sgn, self._sgn_colors, self._sge_colors
  450. )
  451. new_sg_count = Counter()
  452. for (sge_color, sgn_color), count in sg_count.items():
  453. try:
  454. ge_color = self._edge_compatibility[sge_color]
  455. gn_color = self._node_compatibility[sgn_color]
  456. except KeyError:
  457. pass
  458. else:
  459. new_sg_count[ge_color, gn_color] = count
  460. for gn, g_count in g_counts.items():
  461. if all(new_sg_count[x] <= g_count[x] for x in new_sg_count):
  462. # Valid candidate
  463. candidates[sgn].add(gn)
  464. return candidates
  465. def largest_common_subgraph(self, symmetry=True):
  466. """
  467. Find the largest common induced subgraphs between :attr:`subgraph` and
  468. :attr:`graph`.
  469. Parameters
  470. ----------
  471. symmetry: bool
  472. Whether symmetry should be taken into account. If False, found
  473. largest common subgraphs may be symmetrically equivalent.
  474. Yields
  475. ------
  476. dict
  477. The found isomorphism mappings of {graph_node: subgraph_node}.
  478. """
  479. # The networkx VF2 algorithm is slightly funny in when it yields an
  480. # empty dict and when not.
  481. if not self.subgraph:
  482. yield {}
  483. return
  484. elif not self.graph:
  485. return
  486. if symmetry:
  487. _, cosets = self.analyze_symmetry(
  488. self.subgraph, self._sgn_partitions, self._sge_colors
  489. )
  490. constraints = self._make_constraints(cosets)
  491. else:
  492. constraints = []
  493. candidates = self._find_nodecolor_candidates()
  494. if any(candidates.values()):
  495. yield from self._largest_common_subgraph(candidates, constraints)
  496. else:
  497. return
  498. def analyze_symmetry(self, graph, node_partitions, edge_colors):
  499. """
  500. Find a minimal set of permutations and corresponding co-sets that
  501. describe the symmetry of `graph`, given the node and edge equalities
  502. given by `node_partitions` and `edge_colors`, respectively.
  503. Parameters
  504. ----------
  505. graph : networkx.Graph
  506. The graph whose symmetry should be analyzed.
  507. node_partitions : list of sets
  508. A list of sets containing node keys. Node keys in the same set
  509. are considered equivalent. Every node key in `graph` should be in
  510. exactly one of the sets. If all nodes are equivalent, this should
  511. be ``[set(graph.nodes)]``.
  512. edge_colors : dict mapping edges to their colors
  513. A dict mapping every edge in `graph` to its corresponding color.
  514. Edges with the same color are considered equivalent. If all edges
  515. are equivalent, this should be ``{e: 0 for e in graph.edges}``.
  516. Returns
  517. -------
  518. set[frozenset]
  519. The found permutations. This is a set of frozensets of pairs of node
  520. keys which can be exchanged without changing :attr:`subgraph`.
  521. dict[collections.abc.Hashable, set[collections.abc.Hashable]]
  522. The found co-sets. The co-sets is a dictionary of
  523. ``{node key: set of node keys}``.
  524. Every key-value pair describes which ``values`` can be interchanged
  525. without changing nodes less than ``key``.
  526. """
  527. if self._symmetry_cache is not None:
  528. key = hash(
  529. (
  530. tuple(graph.nodes),
  531. tuple(graph.edges),
  532. tuple(map(tuple, node_partitions)),
  533. tuple(edge_colors.items()),
  534. )
  535. )
  536. if key in self._symmetry_cache:
  537. return self._symmetry_cache[key]
  538. node_partitions = list(
  539. self._refine_node_partitions(graph, node_partitions, edge_colors)
  540. )
  541. assert len(node_partitions) == 1
  542. node_partitions = node_partitions[0]
  543. permutations, cosets = self._process_ordered_pair_partitions(
  544. graph, node_partitions, node_partitions, edge_colors
  545. )
  546. if self._symmetry_cache is not None:
  547. self._symmetry_cache[key] = permutations, cosets
  548. return permutations, cosets
  549. def is_isomorphic(self, symmetry=False):
  550. """
  551. Returns True if :attr:`graph` is isomorphic to :attr:`subgraph` and
  552. False otherwise.
  553. Returns
  554. -------
  555. bool
  556. """
  557. return len(self.subgraph) == len(self.graph) and self.subgraph_is_isomorphic(
  558. symmetry
  559. )
  560. def subgraph_is_isomorphic(self, symmetry=False):
  561. """
  562. Returns True if a subgraph of :attr:`graph` is isomorphic to
  563. :attr:`subgraph` and False otherwise.
  564. Returns
  565. -------
  566. bool
  567. """
  568. # symmetry=False, since we only need to know whether there is any
  569. # example; figuring out all symmetry elements probably costs more time
  570. # than it gains.
  571. isom = next(self.subgraph_isomorphisms_iter(symmetry=symmetry), None)
  572. return isom is not None
  573. def isomorphisms_iter(self, symmetry=True):
  574. """
  575. Does the same as :meth:`find_isomorphisms` if :attr:`graph` and
  576. :attr:`subgraph` have the same number of nodes.
  577. """
  578. if len(self.graph) == len(self.subgraph):
  579. yield from self.subgraph_isomorphisms_iter(symmetry=symmetry)
  580. def subgraph_isomorphisms_iter(self, symmetry=True):
  581. """Alternative name for :meth:`find_isomorphisms`."""
  582. return self.find_isomorphisms(symmetry)
  583. def _find_nodecolor_candidates(self):
  584. """
  585. Per node in subgraph find all nodes in graph that have the same color.
  586. """
  587. candidates = defaultdict(set)
  588. for sgn in self.subgraph.nodes:
  589. sgn_color = self._sgn_colors[sgn]
  590. if sgn_color in self._node_compatibility:
  591. gn_color = self._node_compatibility[sgn_color]
  592. candidates[sgn].add(frozenset(self._gn_partitions[gn_color]))
  593. else:
  594. candidates[sgn].add(frozenset())
  595. candidates = dict(candidates)
  596. for sgn, options in candidates.items():
  597. candidates[sgn] = frozenset(options)
  598. return candidates
  599. @staticmethod
  600. def _make_constraints(cosets):
  601. """
  602. Turn cosets into constraints.
  603. """
  604. constraints = []
  605. for node_i, node_ts in cosets.items():
  606. for node_t in node_ts:
  607. if node_i != node_t:
  608. # Node i must be smaller than node t.
  609. constraints.append((node_i, node_t))
  610. return constraints
  611. @staticmethod
  612. def _find_node_edge_color(graph, node_colors, edge_colors):
  613. """
  614. For every node in graph, come up with a color that combines 1) the
  615. color of the node, and 2) the number of edges of a color to each type
  616. of node.
  617. """
  618. counts = defaultdict(lambda: defaultdict(int))
  619. for node1, node2 in graph.edges:
  620. if (node1, node2) in edge_colors:
  621. # FIXME directed graphs
  622. ecolor = edge_colors[node1, node2]
  623. else:
  624. ecolor = edge_colors[node2, node1]
  625. # Count per node how many edges it has of what color to nodes of
  626. # what color
  627. counts[node1][ecolor, node_colors[node2]] += 1
  628. counts[node2][ecolor, node_colors[node1]] += 1
  629. node_edge_colors = {}
  630. for node in graph.nodes:
  631. node_edge_colors[node] = node_colors[node], set(counts[node].items())
  632. return node_edge_colors
  633. @staticmethod
  634. def _get_permutations_by_length(items):
  635. """
  636. Get all permutations of items, but only permute items with the same
  637. length.
  638. >>> found = list(ISMAGS._get_permutations_by_length([[1], [2], [3, 4], [4, 5]]))
  639. >>> answer = [
  640. ... (([1], [2]), ([3, 4], [4, 5])),
  641. ... (([1], [2]), ([4, 5], [3, 4])),
  642. ... (([2], [1]), ([3, 4], [4, 5])),
  643. ... (([2], [1]), ([4, 5], [3, 4])),
  644. ... ]
  645. >>> found == answer
  646. True
  647. """
  648. by_len = defaultdict(list)
  649. for item in items:
  650. by_len[len(item)].append(item)
  651. yield from itertools.product(
  652. *(itertools.permutations(by_len[l]) for l in sorted(by_len))
  653. )
  654. @classmethod
  655. def _refine_node_partitions(cls, graph, node_partitions, edge_colors, branch=False):
  656. """
  657. Given a partition of nodes in graph, make the partitions smaller such
  658. that all nodes in a partition have 1) the same color, and 2) the same
  659. number of edges to specific other partitions.
  660. """
  661. def equal_color(node1, node2):
  662. return node_edge_colors[node1] == node_edge_colors[node2]
  663. node_partitions = list(node_partitions)
  664. node_colors = partition_to_color(node_partitions)
  665. node_edge_colors = cls._find_node_edge_color(graph, node_colors, edge_colors)
  666. if all(
  667. are_all_equal(node_edge_colors[node] for node in partition)
  668. for partition in node_partitions
  669. ):
  670. yield node_partitions
  671. return
  672. new_partitions = []
  673. output = [new_partitions]
  674. for partition in node_partitions:
  675. if not are_all_equal(node_edge_colors[node] for node in partition):
  676. refined = make_partitions(partition, equal_color)
  677. if (
  678. branch
  679. and len(refined) != 1
  680. and len({len(r) for r in refined}) != len([len(r) for r in refined])
  681. ):
  682. # This is where it breaks. There are multiple new cells
  683. # in refined with the same length, and their order
  684. # matters.
  685. # So option 1) Hit it with a big hammer and simply make all
  686. # orderings.
  687. permutations = cls._get_permutations_by_length(refined)
  688. new_output = []
  689. for n_p in output:
  690. for permutation in permutations:
  691. new_output.append(n_p + list(permutation[0]))
  692. output = new_output
  693. else:
  694. for n_p in output:
  695. n_p.extend(sorted(refined, key=len))
  696. else:
  697. for n_p in output:
  698. n_p.append(partition)
  699. for n_p in output:
  700. yield from cls._refine_node_partitions(graph, n_p, edge_colors, branch)
  701. def _edges_of_same_color(self, sgn1, sgn2):
  702. """
  703. Returns all edges in :attr:`graph` that have the same colour as the
  704. edge between sgn1 and sgn2 in :attr:`subgraph`.
  705. """
  706. if (sgn1, sgn2) in self._sge_colors:
  707. # FIXME directed graphs
  708. sge_color = self._sge_colors[sgn1, sgn2]
  709. else:
  710. sge_color = self._sge_colors[sgn2, sgn1]
  711. if sge_color in self._edge_compatibility:
  712. ge_color = self._edge_compatibility[sge_color]
  713. g_edges = self._ge_partitions[ge_color]
  714. else:
  715. g_edges = []
  716. return g_edges
  717. def _map_nodes(self, sgn, candidates, constraints, mapping=None, to_be_mapped=None):
  718. """
  719. Find all subgraph isomorphisms honoring constraints.
  720. """
  721. if mapping is None:
  722. mapping = {}
  723. else:
  724. mapping = mapping.copy()
  725. if to_be_mapped is None:
  726. to_be_mapped = set(self.subgraph.nodes)
  727. # Note, we modify candidates here. Doesn't seem to affect results, but
  728. # remember this.
  729. # candidates = candidates.copy()
  730. sgn_candidates = intersect(candidates[sgn])
  731. candidates[sgn] = frozenset([sgn_candidates])
  732. for gn in sgn_candidates:
  733. # We're going to try to map sgn to gn.
  734. if gn in mapping.values() or sgn not in to_be_mapped:
  735. # gn is already mapped to something
  736. continue # pragma: no cover
  737. # REDUCTION and COMBINATION
  738. mapping[sgn] = gn
  739. # BASECASE
  740. if to_be_mapped == set(mapping.keys()):
  741. yield {v: k for k, v in mapping.items()}
  742. continue
  743. left_to_map = to_be_mapped - set(mapping.keys())
  744. new_candidates = candidates.copy()
  745. sgn_neighbours = set(self.subgraph[sgn])
  746. not_gn_neighbours = set(self.graph.nodes) - set(self.graph[gn])
  747. for sgn2 in left_to_map:
  748. if sgn2 not in sgn_neighbours:
  749. gn2_options = not_gn_neighbours
  750. else:
  751. # Get all edges to gn of the right color:
  752. g_edges = self._edges_of_same_color(sgn, sgn2)
  753. # FIXME directed graphs
  754. # And all nodes involved in those which are connected to gn
  755. gn2_options = {n for e in g_edges for n in e if gn in e}
  756. # Node color compatibility should be taken care of by the
  757. # initial candidate lists made by find_subgraphs
  758. # Add gn2_options to the right collection. Since new_candidates
  759. # is a dict of frozensets of frozensets of node indices it's
  760. # a bit clunky. We can't do .add, and + also doesn't work. We
  761. # could do |, but I deem union to be clearer.
  762. new_candidates[sgn2] = new_candidates[sgn2].union(
  763. [frozenset(gn2_options)]
  764. )
  765. if (sgn, sgn2) in constraints:
  766. gn2_options = {gn2 for gn2 in self.graph if gn2 > gn}
  767. elif (sgn2, sgn) in constraints:
  768. gn2_options = {gn2 for gn2 in self.graph if gn2 < gn}
  769. else:
  770. continue # pragma: no cover
  771. new_candidates[sgn2] = new_candidates[sgn2].union(
  772. [frozenset(gn2_options)]
  773. )
  774. # The next node is the one that is unmapped and has fewest
  775. # candidates
  776. # Pylint disables because it's a one-shot function.
  777. next_sgn = min(
  778. left_to_map, key=lambda n: min(new_candidates[n], key=len)
  779. ) # pylint: disable=cell-var-from-loop
  780. yield from self._map_nodes(
  781. next_sgn,
  782. new_candidates,
  783. constraints,
  784. mapping=mapping,
  785. to_be_mapped=to_be_mapped,
  786. )
  787. # Unmap sgn-gn. Strictly not necessary since it'd get overwritten
  788. # when making a new mapping for sgn.
  789. # del mapping[sgn]
  790. def _largest_common_subgraph(self, candidates, constraints, to_be_mapped=None):
  791. """
  792. Find all largest common subgraphs honoring constraints.
  793. """
  794. if to_be_mapped is None:
  795. to_be_mapped = {frozenset(self.subgraph.nodes)}
  796. # The LCS problem is basically a repeated subgraph isomorphism problem
  797. # with smaller and smaller subgraphs. We store the nodes that are
  798. # "part of" the subgraph in to_be_mapped, and we make it a little
  799. # smaller every iteration.
  800. # pylint disable because it's guarded against by default value
  801. current_size = len(
  802. next(iter(to_be_mapped), [])
  803. ) # pylint: disable=stop-iteration-return
  804. found_iso = False
  805. if current_size <= len(self.graph):
  806. # There's no point in trying to find isomorphisms of
  807. # graph >= subgraph if subgraph has more nodes than graph.
  808. # Try the isomorphism first with the nodes with lowest ID. So sort
  809. # them. Those are more likely to be part of the final
  810. # correspondence. This makes finding the first answer(s) faster. In
  811. # theory.
  812. for nodes in sorted(to_be_mapped, key=sorted):
  813. # Find the isomorphism between subgraph[to_be_mapped] <= graph
  814. next_sgn = min(nodes, key=lambda n: min(candidates[n], key=len))
  815. isomorphs = self._map_nodes(
  816. next_sgn, candidates, constraints, to_be_mapped=nodes
  817. )
  818. # This is effectively `yield from isomorphs`, except that we look
  819. # whether an item was yielded.
  820. try:
  821. item = next(isomorphs)
  822. except StopIteration:
  823. pass
  824. else:
  825. yield item
  826. yield from isomorphs
  827. found_iso = True
  828. # BASECASE
  829. if found_iso or current_size == 1:
  830. # Shrinking has no point because either 1) we end up with a smaller
  831. # common subgraph (and we want the largest), or 2) there'll be no
  832. # more subgraph.
  833. return
  834. left_to_be_mapped = set()
  835. for nodes in to_be_mapped:
  836. for sgn in nodes:
  837. # We're going to remove sgn from to_be_mapped, but subject to
  838. # symmetry constraints. We know that for every constraint we
  839. # have those subgraph nodes are equal. So whenever we would
  840. # remove the lower part of a constraint, remove the higher
  841. # instead. This is all dealth with by _remove_node. And because
  842. # left_to_be_mapped is a set, we don't do double work.
  843. # And finally, make the subgraph one node smaller.
  844. # REDUCTION
  845. new_nodes = self._remove_node(sgn, nodes, constraints)
  846. left_to_be_mapped.add(new_nodes)
  847. # COMBINATION
  848. yield from self._largest_common_subgraph(
  849. candidates, constraints, to_be_mapped=left_to_be_mapped
  850. )
  851. @staticmethod
  852. def _remove_node(node, nodes, constraints):
  853. """
  854. Returns a new set where node has been removed from nodes, subject to
  855. symmetry constraints. We know, that for every constraint we have
  856. those subgraph nodes are equal. So whenever we would remove the
  857. lower part of a constraint, remove the higher instead.
  858. """
  859. while True:
  860. for low, high in constraints:
  861. if low == node and high in nodes:
  862. node = high
  863. break
  864. else: # no break, couldn't find node in constraints
  865. break
  866. return frozenset(nodes - {node})
  867. @staticmethod
  868. def _find_permutations(top_partitions, bottom_partitions):
  869. """
  870. Return the pairs of top/bottom partitions where the partitions are
  871. different. Ensures that all partitions in both top and bottom
  872. partitions have size 1.
  873. """
  874. # Find permutations
  875. permutations = set()
  876. for top, bot in zip(top_partitions, bottom_partitions):
  877. # top and bot have only one element
  878. if len(top) != 1 or len(bot) != 1:
  879. raise IndexError(
  880. "Not all nodes are coupled. This is"
  881. f" impossible: {top_partitions}, {bottom_partitions}"
  882. )
  883. if top != bot:
  884. permutations.add(frozenset((next(iter(top)), next(iter(bot)))))
  885. return permutations
  886. @staticmethod
  887. def _update_orbits(orbits, permutations):
  888. """
  889. Update orbits based on permutations. Orbits is modified in place.
  890. For every pair of items in permutations their respective orbits are
  891. merged.
  892. """
  893. for permutation in permutations:
  894. node, node2 = permutation
  895. # Find the orbits that contain node and node2, and replace the
  896. # orbit containing node with the union
  897. first = second = None
  898. for idx, orbit in enumerate(orbits):
  899. if first is not None and second is not None:
  900. break
  901. if node in orbit:
  902. first = idx
  903. if node2 in orbit:
  904. second = idx
  905. if first != second:
  906. orbits[first].update(orbits[second])
  907. del orbits[second]
  908. def _couple_nodes(
  909. self,
  910. top_partitions,
  911. bottom_partitions,
  912. pair_idx,
  913. t_node,
  914. b_node,
  915. graph,
  916. edge_colors,
  917. ):
  918. """
  919. Generate new partitions from top and bottom_partitions where t_node is
  920. coupled to b_node. pair_idx is the index of the partitions where t_ and
  921. b_node can be found.
  922. """
  923. t_partition = top_partitions[pair_idx]
  924. b_partition = bottom_partitions[pair_idx]
  925. assert t_node in t_partition and b_node in b_partition
  926. # Couple node to node2. This means they get their own partition
  927. new_top_partitions = [top.copy() for top in top_partitions]
  928. new_bottom_partitions = [bot.copy() for bot in bottom_partitions]
  929. new_t_groups = {t_node}, t_partition - {t_node}
  930. new_b_groups = {b_node}, b_partition - {b_node}
  931. # Replace the old partitions with the coupled ones
  932. del new_top_partitions[pair_idx]
  933. del new_bottom_partitions[pair_idx]
  934. new_top_partitions[pair_idx:pair_idx] = new_t_groups
  935. new_bottom_partitions[pair_idx:pair_idx] = new_b_groups
  936. new_top_partitions = self._refine_node_partitions(
  937. graph, new_top_partitions, edge_colors
  938. )
  939. new_bottom_partitions = self._refine_node_partitions(
  940. graph, new_bottom_partitions, edge_colors, branch=True
  941. )
  942. new_top_partitions = list(new_top_partitions)
  943. assert len(new_top_partitions) == 1
  944. new_top_partitions = new_top_partitions[0]
  945. for bot in new_bottom_partitions:
  946. yield list(new_top_partitions), bot
  947. def _process_ordered_pair_partitions(
  948. self,
  949. graph,
  950. top_partitions,
  951. bottom_partitions,
  952. edge_colors,
  953. orbits=None,
  954. cosets=None,
  955. ):
  956. """
  957. Processes ordered pair partitions as per the reference paper. Finds and
  958. returns all permutations and cosets that leave the graph unchanged.
  959. """
  960. if orbits is None:
  961. orbits = [{node} for node in graph.nodes]
  962. else:
  963. # Note that we don't copy orbits when we are given one. This means
  964. # we leak information between the recursive branches. This is
  965. # intentional!
  966. orbits = orbits
  967. if cosets is None:
  968. cosets = {}
  969. else:
  970. cosets = cosets.copy()
  971. assert all(
  972. len(t_p) == len(b_p) for t_p, b_p in zip(top_partitions, bottom_partitions)
  973. )
  974. # BASECASE
  975. if all(len(top) == 1 for top in top_partitions):
  976. # All nodes are mapped
  977. permutations = self._find_permutations(top_partitions, bottom_partitions)
  978. self._update_orbits(orbits, permutations)
  979. if permutations:
  980. return [permutations], cosets
  981. else:
  982. return [], cosets
  983. permutations = []
  984. unmapped_nodes = {
  985. (node, idx)
  986. for idx, t_partition in enumerate(top_partitions)
  987. for node in t_partition
  988. if len(t_partition) > 1
  989. }
  990. node, pair_idx = min(unmapped_nodes)
  991. b_partition = bottom_partitions[pair_idx]
  992. for node2 in sorted(b_partition):
  993. if len(b_partition) == 1:
  994. # Can never result in symmetry
  995. continue
  996. if node != node2 and any(
  997. node in orbit and node2 in orbit for orbit in orbits
  998. ):
  999. # Orbit prune branch
  1000. continue
  1001. # REDUCTION
  1002. # Couple node to node2
  1003. partitions = self._couple_nodes(
  1004. top_partitions,
  1005. bottom_partitions,
  1006. pair_idx,
  1007. node,
  1008. node2,
  1009. graph,
  1010. edge_colors,
  1011. )
  1012. for opp in partitions:
  1013. new_top_partitions, new_bottom_partitions = opp
  1014. new_perms, new_cosets = self._process_ordered_pair_partitions(
  1015. graph,
  1016. new_top_partitions,
  1017. new_bottom_partitions,
  1018. edge_colors,
  1019. orbits,
  1020. cosets,
  1021. )
  1022. # COMBINATION
  1023. permutations += new_perms
  1024. cosets.update(new_cosets)
  1025. mapped = {
  1026. k
  1027. for top, bottom in zip(top_partitions, bottom_partitions)
  1028. for k in top
  1029. if len(top) == 1 and top == bottom
  1030. }
  1031. ks = {k for k in graph.nodes if k < node}
  1032. # Have all nodes with ID < node been mapped?
  1033. find_coset = ks <= mapped and node not in cosets
  1034. if find_coset:
  1035. # Find the orbit that contains node
  1036. for orbit in orbits:
  1037. if node in orbit:
  1038. cosets[node] = orbit.copy()
  1039. return permutations, cosets