Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/networkx/algorithms/isomorphism/ismags.py: 14%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

456 statements  

1""" 

2ISMAGS Algorithm 

3================ 

4 

5Provides a Python implementation of the ISMAGS algorithm. [1]_ 

6 

7It is capable of finding (subgraph) isomorphisms between two graphs, taking the 

8symmetry of the subgraph into account. In most cases the VF2 algorithm is 

9faster (at least on small graphs) than this implementation, but in some cases 

10there is an exponential number of isomorphisms that are symmetrically 

11equivalent. In that case, the ISMAGS algorithm will provide only one solution 

12per symmetry group. 

13 

14>>> petersen = nx.petersen_graph() 

15>>> ismags = nx.isomorphism.ISMAGS(petersen, petersen) 

16>>> isomorphisms = list(ismags.isomorphisms_iter(symmetry=False)) 

17>>> len(isomorphisms) 

18120 

19>>> isomorphisms = list(ismags.isomorphisms_iter(symmetry=True)) 

20>>> answer = [{0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9}] 

21>>> answer == isomorphisms 

22True 

23 

24In addition, this implementation also provides an interface to find the 

25largest common induced subgraph [2]_ between any two graphs, again taking 

26symmetry into account. Given `graph` and `subgraph` the algorithm will remove 

27nodes from the `subgraph` until `subgraph` is isomorphic to a subgraph of 

28`graph`. Since only the symmetry of `subgraph` is taken into account it is 

29worth thinking about how you provide your graphs: 

30 

31>>> graph1 = nx.path_graph(4) 

32>>> graph2 = nx.star_graph(3) 

33>>> ismags = nx.isomorphism.ISMAGS(graph1, graph2) 

34>>> ismags.is_isomorphic() 

35False 

36>>> largest_common_subgraph = list(ismags.largest_common_subgraph()) 

37>>> answer = [{1: 0, 0: 1, 2: 2}, {2: 0, 1: 1, 3: 2}] 

38>>> answer == largest_common_subgraph 

39True 

40>>> ismags2 = nx.isomorphism.ISMAGS(graph2, graph1) 

41>>> largest_common_subgraph = list(ismags2.largest_common_subgraph()) 

42>>> answer = [ 

43... {1: 0, 0: 1, 2: 2}, 

44... {1: 0, 0: 1, 3: 2}, 

45... {2: 0, 0: 1, 1: 2}, 

46... {2: 0, 0: 1, 3: 2}, 

47... {3: 0, 0: 1, 1: 2}, 

48... {3: 0, 0: 1, 2: 2}, 

49... ] 

50>>> answer == largest_common_subgraph 

51True 

52 

53However, when not taking symmetry into account, it doesn't matter: 

54 

55>>> largest_common_subgraph = list(ismags.largest_common_subgraph(symmetry=False)) 

56>>> answer = [ 

57... {1: 0, 0: 1, 2: 2}, 

58... {1: 0, 2: 1, 0: 2}, 

59... {2: 0, 1: 1, 3: 2}, 

60... {2: 0, 3: 1, 1: 2}, 

61... {1: 0, 0: 1, 2: 3}, 

62... {1: 0, 2: 1, 0: 3}, 

63... {2: 0, 1: 1, 3: 3}, 

64... {2: 0, 3: 1, 1: 3}, 

65... {1: 0, 0: 2, 2: 3}, 

66... {1: 0, 2: 2, 0: 3}, 

67... {2: 0, 1: 2, 3: 3}, 

68... {2: 0, 3: 2, 1: 3}, 

69... ] 

70>>> answer == largest_common_subgraph 

71True 

72>>> largest_common_subgraph = list(ismags2.largest_common_subgraph(symmetry=False)) 

73>>> answer = [ 

74... {1: 0, 0: 1, 2: 2}, 

75... {1: 0, 0: 1, 3: 2}, 

76... {2: 0, 0: 1, 1: 2}, 

77... {2: 0, 0: 1, 3: 2}, 

78... {3: 0, 0: 1, 1: 2}, 

79... {3: 0, 0: 1, 2: 2}, 

80... {1: 1, 0: 2, 2: 3}, 

81... {1: 1, 0: 2, 3: 3}, 

82... {2: 1, 0: 2, 1: 3}, 

83... {2: 1, 0: 2, 3: 3}, 

84... {3: 1, 0: 2, 1: 3}, 

85... {3: 1, 0: 2, 2: 3}, 

86... ] 

87>>> answer == largest_common_subgraph 

88True 

89 

90Notes 

91----- 

92- The current implementation works for undirected graphs only. The algorithm 

93 in general should work for directed graphs as well though. 

94- Node keys for both provided graphs need to be fully orderable as well as 

95 hashable. 

96- Node and edge equality is assumed to be transitive: if A is equal to B, and 

97 B is equal to C, then A is equal to C. 

98 

99References 

100---------- 

101.. [1] M. Houbraken, S. Demeyer, T. Michoel, P. Audenaert, D. Colle, 

102 M. Pickavet, "The Index-Based Subgraph Matching Algorithm with General 

103 Symmetries (ISMAGS): Exploiting Symmetry for Faster Subgraph 

104 Enumeration", PLoS One 9(5): e97896, 2014. 

105 https://doi.org/10.1371/journal.pone.0097896 

106.. [2] https://en.wikipedia.org/wiki/Maximum_common_induced_subgraph 

107""" 

108 

109__all__ = ["ISMAGS"] 

110 

111import itertools 

112from collections import Counter, defaultdict 

113from functools import reduce, wraps 

114 

115 

116def are_all_equal(iterable): 

117 """ 

118 Returns ``True`` if and only if all elements in `iterable` are equal; and 

119 ``False`` otherwise. 

120 

121 Parameters 

122 ---------- 

123 iterable: collections.abc.Iterable 

124 The container whose elements will be checked. 

125 

126 Returns 

127 ------- 

128 bool 

129 ``True`` iff all elements in `iterable` compare equal, ``False`` 

130 otherwise. 

131 """ 

132 try: 

133 shape = iterable.shape 

134 except AttributeError: 

135 pass 

136 else: 

137 if len(shape) > 1: 

138 message = "The function does not works on multidimensional arrays." 

139 raise NotImplementedError(message) from None 

140 

141 iterator = iter(iterable) 

142 first = next(iterator, None) 

143 return all(item == first for item in iterator) 

144 

145 

146def make_partitions(items, test): 

147 """ 

148 Partitions items into sets based on the outcome of ``test(item1, item2)``. 

149 Pairs of items for which `test` returns `True` end up in the same set. 

150 

151 Parameters 

152 ---------- 

153 items : collections.abc.Iterable[collections.abc.Hashable] 

154 Items to partition 

155 test : collections.abc.Callable[collections.abc.Hashable, collections.abc.Hashable] 

156 A function that will be called with 2 arguments, taken from items. 

157 Should return `True` if those 2 items need to end up in the same 

158 partition, and `False` otherwise. 

159 

160 Returns 

161 ------- 

162 list[set] 

163 A list of sets, with each set containing part of the items in `items`, 

164 such that ``all(test(*pair) for pair in itertools.combinations(set, 2)) 

165 == True`` 

166 

167 Notes 

168 ----- 

169 The function `test` is assumed to be transitive: if ``test(a, b)`` and 

170 ``test(b, c)`` return ``True``, then ``test(a, c)`` must also be ``True``. 

171 """ 

172 partitions = [] 

173 for item in items: 

174 for partition in partitions: 

175 p_item = next(iter(partition)) 

176 if test(item, p_item): 

177 partition.add(item) 

178 break 

179 else: # No break 

180 partitions.append({item}) 

181 return partitions 

182 

183 

184def partition_to_color(partitions): 

185 """ 

186 Creates a dictionary that maps each item in each partition to the index of 

187 the partition to which it belongs. 

188 

189 Parameters 

190 ---------- 

191 partitions: collections.abc.Sequence[collections.abc.Iterable] 

192 As returned by :func:`make_partitions`. 

193 

194 Returns 

195 ------- 

196 dict 

197 """ 

198 colors = {} 

199 for color, keys in enumerate(partitions): 

200 for key in keys: 

201 colors[key] = color 

202 return colors 

203 

204 

205def intersect(collection_of_sets): 

206 """ 

207 Given an collection of sets, returns the intersection of those sets. 

208 

209 Parameters 

210 ---------- 

211 collection_of_sets: collections.abc.Collection[set] 

212 A collection of sets. 

213 

214 Returns 

215 ------- 

216 set 

217 An intersection of all sets in `collection_of_sets`. Will have the same 

218 type as the item initially taken from `collection_of_sets`. 

219 """ 

220 collection_of_sets = list(collection_of_sets) 

221 first = collection_of_sets.pop() 

222 out = reduce(set.intersection, collection_of_sets, set(first)) 

223 return type(first)(out) 

224 

225 

226class ISMAGS: 

227 """ 

228 Implements the ISMAGS subgraph matching algorithm. [1]_ ISMAGS stands for 

229 "Index-based Subgraph Matching Algorithm with General Symmetries". As the 

230 name implies, it is symmetry aware and will only generate non-symmetric 

231 isomorphisms. 

232 

233 Notes 

234 ----- 

235 The implementation imposes additional conditions compared to the VF2 

236 algorithm on the graphs provided and the comparison functions 

237 (:attr:`node_equality` and :attr:`edge_equality`): 

238 

239 - Node keys in both graphs must be orderable as well as hashable. 

240 - Equality must be transitive: if A is equal to B, and B is equal to C, 

241 then A must be equal to C. 

242 

243 Attributes 

244 ---------- 

245 graph: networkx.Graph 

246 subgraph: networkx.Graph 

247 node_equality: collections.abc.Callable 

248 The function called to see if two nodes should be considered equal. 

249 It's signature looks like this: 

250 ``f(graph1: networkx.Graph, node1, graph2: networkx.Graph, node2) -> bool``. 

251 `node1` is a node in `graph1`, and `node2` a node in `graph2`. 

252 Constructed from the argument `node_match`. 

253 edge_equality: collections.abc.Callable 

254 The function called to see if two edges should be considered equal. 

255 It's signature looks like this: 

256 ``f(graph1: networkx.Graph, edge1, graph2: networkx.Graph, edge2) -> bool``. 

257 `edge1` is an edge in `graph1`, and `edge2` an edge in `graph2`. 

258 Constructed from the argument `edge_match`. 

259 

260 References 

261 ---------- 

262 .. [1] M. Houbraken, S. Demeyer, T. Michoel, P. Audenaert, D. Colle, 

263 M. Pickavet, "The Index-Based Subgraph Matching Algorithm with General 

264 Symmetries (ISMAGS): Exploiting Symmetry for Faster Subgraph 

265 Enumeration", PLoS One 9(5): e97896, 2014. 

266 https://doi.org/10.1371/journal.pone.0097896 

267 """ 

268 

269 def __init__(self, graph, subgraph, node_match=None, edge_match=None, cache=None): 

270 """ 

271 Parameters 

272 ---------- 

273 graph: networkx.Graph 

274 subgraph: networkx.Graph 

275 node_match: collections.abc.Callable or None 

276 Function used to determine whether two nodes are equivalent. Its 

277 signature should look like ``f(n1: dict, n2: dict) -> bool``, with 

278 `n1` and `n2` node property dicts. See also 

279 :func:`~networkx.algorithms.isomorphism.categorical_node_match` and 

280 friends. 

281 If `None`, all nodes are considered equal. 

282 edge_match: collections.abc.Callable or None 

283 Function used to determine whether two edges are equivalent. Its 

284 signature should look like ``f(e1: dict, e2: dict) -> bool``, with 

285 `e1` and `e2` edge property dicts. See also 

286 :func:`~networkx.algorithms.isomorphism.categorical_edge_match` and 

287 friends. 

288 If `None`, all edges are considered equal. 

289 cache: collections.abc.Mapping 

290 A cache used for caching graph symmetries. 

291 """ 

292 # TODO: graph and subgraph setter methods that invalidate the caches. 

293 # TODO: allow for precomputed partitions and colors 

294 self.graph = graph 

295 self.subgraph = subgraph 

296 self._symmetry_cache = cache 

297 # Naming conventions are taken from the original paper. For your 

298 # sanity: 

299 # sg: subgraph 

300 # g: graph 

301 # e: edge(s) 

302 # n: node(s) 

303 # So: sgn means "subgraph nodes". 

304 self._sgn_partitions_ = None 

305 self._sge_partitions_ = None 

306 

307 self._sgn_colors_ = None 

308 self._sge_colors_ = None 

309 

310 self._gn_partitions_ = None 

311 self._ge_partitions_ = None 

312 

313 self._gn_colors_ = None 

314 self._ge_colors_ = None 

315 

316 self._node_compat_ = None 

317 self._edge_compat_ = None 

318 

319 if node_match is None: 

320 self.node_equality = self._node_match_maker(lambda n1, n2: True) 

321 self._sgn_partitions_ = [set(self.subgraph.nodes)] 

322 self._gn_partitions_ = [set(self.graph.nodes)] 

323 self._node_compat_ = {0: 0} 

324 else: 

325 self.node_equality = self._node_match_maker(node_match) 

326 if edge_match is None: 

327 self.edge_equality = self._edge_match_maker(lambda e1, e2: True) 

328 self._sge_partitions_ = [set(self.subgraph.edges)] 

329 self._ge_partitions_ = [set(self.graph.edges)] 

330 self._edge_compat_ = {0: 0} 

331 else: 

332 self.edge_equality = self._edge_match_maker(edge_match) 

333 

334 @property 

335 def _sgn_partitions(self): 

336 if self._sgn_partitions_ is None: 

337 

338 def nodematch(node1, node2): 

339 return self.node_equality(self.subgraph, node1, self.subgraph, node2) 

340 

341 self._sgn_partitions_ = make_partitions(self.subgraph.nodes, nodematch) 

342 return self._sgn_partitions_ 

343 

344 @property 

345 def _sge_partitions(self): 

346 if self._sge_partitions_ is None: 

347 

348 def edgematch(edge1, edge2): 

349 return self.edge_equality(self.subgraph, edge1, self.subgraph, edge2) 

350 

351 self._sge_partitions_ = make_partitions(self.subgraph.edges, edgematch) 

352 return self._sge_partitions_ 

353 

354 @property 

355 def _gn_partitions(self): 

356 if self._gn_partitions_ is None: 

357 

358 def nodematch(node1, node2): 

359 return self.node_equality(self.graph, node1, self.graph, node2) 

360 

361 self._gn_partitions_ = make_partitions(self.graph.nodes, nodematch) 

362 return self._gn_partitions_ 

363 

364 @property 

365 def _ge_partitions(self): 

366 if self._ge_partitions_ is None: 

367 

368 def edgematch(edge1, edge2): 

369 return self.edge_equality(self.graph, edge1, self.graph, edge2) 

370 

371 self._ge_partitions_ = make_partitions(self.graph.edges, edgematch) 

372 return self._ge_partitions_ 

373 

374 @property 

375 def _sgn_colors(self): 

376 if self._sgn_colors_ is None: 

377 self._sgn_colors_ = partition_to_color(self._sgn_partitions) 

378 return self._sgn_colors_ 

379 

380 @property 

381 def _sge_colors(self): 

382 if self._sge_colors_ is None: 

383 self._sge_colors_ = partition_to_color(self._sge_partitions) 

384 return self._sge_colors_ 

385 

386 @property 

387 def _gn_colors(self): 

388 if self._gn_colors_ is None: 

389 self._gn_colors_ = partition_to_color(self._gn_partitions) 

390 return self._gn_colors_ 

391 

392 @property 

393 def _ge_colors(self): 

394 if self._ge_colors_ is None: 

395 self._ge_colors_ = partition_to_color(self._ge_partitions) 

396 return self._ge_colors_ 

397 

398 @property 

399 def _node_compatibility(self): 

400 if self._node_compat_ is not None: 

401 return self._node_compat_ 

402 self._node_compat_ = {} 

403 for sgn_part_color, gn_part_color in itertools.product( 

404 range(len(self._sgn_partitions)), range(len(self._gn_partitions)) 

405 ): 

406 sgn = next(iter(self._sgn_partitions[sgn_part_color])) 

407 gn = next(iter(self._gn_partitions[gn_part_color])) 

408 if self.node_equality(self.subgraph, sgn, self.graph, gn): 

409 self._node_compat_[sgn_part_color] = gn_part_color 

410 return self._node_compat_ 

411 

412 @property 

413 def _edge_compatibility(self): 

414 if self._edge_compat_ is not None: 

415 return self._edge_compat_ 

416 self._edge_compat_ = {} 

417 for sge_part_color, ge_part_color in itertools.product( 

418 range(len(self._sge_partitions)), range(len(self._ge_partitions)) 

419 ): 

420 sge = next(iter(self._sge_partitions[sge_part_color])) 

421 ge = next(iter(self._ge_partitions[ge_part_color])) 

422 if self.edge_equality(self.subgraph, sge, self.graph, ge): 

423 self._edge_compat_[sge_part_color] = ge_part_color 

424 return self._edge_compat_ 

425 

426 @staticmethod 

427 def _node_match_maker(cmp): 

428 @wraps(cmp) 

429 def comparer(graph1, node1, graph2, node2): 

430 return cmp(graph1.nodes[node1], graph2.nodes[node2]) 

431 

432 return comparer 

433 

434 @staticmethod 

435 def _edge_match_maker(cmp): 

436 @wraps(cmp) 

437 def comparer(graph1, edge1, graph2, edge2): 

438 return cmp(graph1.edges[edge1], graph2.edges[edge2]) 

439 

440 return comparer 

441 

442 def find_isomorphisms(self, symmetry=True): 

443 """Find all subgraph isomorphisms between subgraph and graph 

444 

445 Finds isomorphisms where :attr:`subgraph` <= :attr:`graph`. 

446 

447 Parameters 

448 ---------- 

449 symmetry: bool 

450 Whether symmetry should be taken into account. If False, found 

451 isomorphisms may be symmetrically equivalent. 

452 

453 Yields 

454 ------ 

455 dict 

456 The found isomorphism mappings of {graph_node: subgraph_node}. 

457 """ 

458 # The networkx VF2 algorithm is slightly funny in when it yields an 

459 # empty dict and when not. 

460 if not self.subgraph: 

461 yield {} 

462 return 

463 elif not self.graph: 

464 return 

465 elif len(self.graph) < len(self.subgraph): 

466 return 

467 

468 if symmetry: 

469 _, cosets = self.analyze_symmetry( 

470 self.subgraph, self._sgn_partitions, self._sge_colors 

471 ) 

472 constraints = self._make_constraints(cosets) 

473 else: 

474 constraints = [] 

475 

476 candidates = self._find_nodecolor_candidates() 

477 la_candidates = self._get_lookahead_candidates() 

478 for sgn in self.subgraph: 

479 extra_candidates = la_candidates[sgn] 

480 if extra_candidates: 

481 candidates[sgn] = candidates[sgn] | {frozenset(extra_candidates)} 

482 

483 if any(candidates.values()): 

484 start_sgn = min(candidates, key=lambda n: min(candidates[n], key=len)) 

485 candidates[start_sgn] = (intersect(candidates[start_sgn]),) 

486 yield from self._map_nodes(start_sgn, candidates, constraints) 

487 else: 

488 return 

489 

490 @staticmethod 

491 def _find_neighbor_color_count(graph, node, node_color, edge_color): 

492 """ 

493 For `node` in `graph`, count the number of edges of a specific color 

494 it has to nodes of a specific color. 

495 """ 

496 counts = Counter() 

497 neighbors = graph[node] 

498 for neighbor in neighbors: 

499 n_color = node_color[neighbor] 

500 if (node, neighbor) in edge_color: 

501 e_color = edge_color[node, neighbor] 

502 else: 

503 e_color = edge_color[neighbor, node] 

504 counts[e_color, n_color] += 1 

505 return counts 

506 

507 def _get_lookahead_candidates(self): 

508 """ 

509 Returns a mapping of {subgraph node: collection of graph nodes} for 

510 which the graph nodes are feasible candidates for the subgraph node, as 

511 determined by looking ahead one edge. 

512 """ 

513 g_counts = {} 

514 for gn in self.graph: 

515 g_counts[gn] = self._find_neighbor_color_count( 

516 self.graph, gn, self._gn_colors, self._ge_colors 

517 ) 

518 candidates = defaultdict(set) 

519 for sgn in self.subgraph: 

520 sg_count = self._find_neighbor_color_count( 

521 self.subgraph, sgn, self._sgn_colors, self._sge_colors 

522 ) 

523 new_sg_count = Counter() 

524 for (sge_color, sgn_color), count in sg_count.items(): 

525 try: 

526 ge_color = self._edge_compatibility[sge_color] 

527 gn_color = self._node_compatibility[sgn_color] 

528 except KeyError: 

529 pass 

530 else: 

531 new_sg_count[ge_color, gn_color] = count 

532 

533 for gn, g_count in g_counts.items(): 

534 if all(new_sg_count[x] <= g_count[x] for x in new_sg_count): 

535 # Valid candidate 

536 candidates[sgn].add(gn) 

537 return candidates 

538 

539 def largest_common_subgraph(self, symmetry=True): 

540 """ 

541 Find the largest common induced subgraphs between :attr:`subgraph` and 

542 :attr:`graph`. 

543 

544 Parameters 

545 ---------- 

546 symmetry: bool 

547 Whether symmetry should be taken into account. If False, found 

548 largest common subgraphs may be symmetrically equivalent. 

549 

550 Yields 

551 ------ 

552 dict 

553 The found isomorphism mappings of {graph_node: subgraph_node}. 

554 """ 

555 # The networkx VF2 algorithm is slightly funny in when it yields an 

556 # empty dict and when not. 

557 if not self.subgraph: 

558 yield {} 

559 return 

560 elif not self.graph: 

561 return 

562 

563 if symmetry: 

564 _, cosets = self.analyze_symmetry( 

565 self.subgraph, self._sgn_partitions, self._sge_colors 

566 ) 

567 constraints = self._make_constraints(cosets) 

568 else: 

569 constraints = [] 

570 

571 candidates = self._find_nodecolor_candidates() 

572 

573 if any(candidates.values()): 

574 yield from self._largest_common_subgraph(candidates, constraints) 

575 else: 

576 return 

577 

578 def analyze_symmetry(self, graph, node_partitions, edge_colors): 

579 """ 

580 Find a minimal set of permutations and corresponding co-sets that 

581 describe the symmetry of `graph`, given the node and edge equalities 

582 given by `node_partitions` and `edge_colors`, respectively. 

583 

584 Parameters 

585 ---------- 

586 graph : networkx.Graph 

587 The graph whose symmetry should be analyzed. 

588 node_partitions : list of sets 

589 A list of sets containing node keys. Node keys in the same set 

590 are considered equivalent. Every node key in `graph` should be in 

591 exactly one of the sets. If all nodes are equivalent, this should 

592 be ``[set(graph.nodes)]``. 

593 edge_colors : dict mapping edges to their colors 

594 A dict mapping every edge in `graph` to its corresponding color. 

595 Edges with the same color are considered equivalent. If all edges 

596 are equivalent, this should be ``{e: 0 for e in graph.edges}``. 

597 

598 

599 Returns 

600 ------- 

601 set[frozenset] 

602 The found permutations. This is a set of frozensets of pairs of node 

603 keys which can be exchanged without changing :attr:`subgraph`. 

604 dict[collections.abc.Hashable, set[collections.abc.Hashable]] 

605 The found co-sets. The co-sets is a dictionary of 

606 ``{node key: set of node keys}``. 

607 Every key-value pair describes which ``values`` can be interchanged 

608 without changing nodes less than ``key``. 

609 """ 

610 if self._symmetry_cache is not None: 

611 key = hash( 

612 ( 

613 tuple(graph.nodes), 

614 tuple(graph.edges), 

615 tuple(map(tuple, node_partitions)), 

616 tuple(edge_colors.items()), 

617 ) 

618 ) 

619 if key in self._symmetry_cache: 

620 return self._symmetry_cache[key] 

621 node_partitions = list( 

622 self._refine_node_partitions(graph, node_partitions, edge_colors) 

623 ) 

624 assert len(node_partitions) == 1 

625 node_partitions = node_partitions[0] 

626 permutations, cosets = self._process_ordered_pair_partitions( 

627 graph, node_partitions, node_partitions, edge_colors 

628 ) 

629 if self._symmetry_cache is not None: 

630 self._symmetry_cache[key] = permutations, cosets 

631 return permutations, cosets 

632 

633 def is_isomorphic(self, symmetry=False): 

634 """ 

635 Returns True if :attr:`graph` is isomorphic to :attr:`subgraph` and 

636 False otherwise. 

637 

638 Returns 

639 ------- 

640 bool 

641 """ 

642 return len(self.subgraph) == len(self.graph) and self.subgraph_is_isomorphic( 

643 symmetry 

644 ) 

645 

646 def subgraph_is_isomorphic(self, symmetry=False): 

647 """ 

648 Returns True if a subgraph of :attr:`graph` is isomorphic to 

649 :attr:`subgraph` and False otherwise. 

650 

651 Returns 

652 ------- 

653 bool 

654 """ 

655 # symmetry=False, since we only need to know whether there is any 

656 # example; figuring out all symmetry elements probably costs more time 

657 # than it gains. 

658 isom = next(self.subgraph_isomorphisms_iter(symmetry=symmetry), None) 

659 return isom is not None 

660 

661 def isomorphisms_iter(self, symmetry=True): 

662 """ 

663 Does the same as :meth:`find_isomorphisms` if :attr:`graph` and 

664 :attr:`subgraph` have the same number of nodes. 

665 """ 

666 if len(self.graph) == len(self.subgraph): 

667 yield from self.subgraph_isomorphisms_iter(symmetry=symmetry) 

668 

669 def subgraph_isomorphisms_iter(self, symmetry=True): 

670 """Alternative name for :meth:`find_isomorphisms`.""" 

671 return self.find_isomorphisms(symmetry) 

672 

673 def _find_nodecolor_candidates(self): 

674 """ 

675 Per node in subgraph find all nodes in graph that have the same color. 

676 """ 

677 candidates = defaultdict(set) 

678 for sgn in self.subgraph.nodes: 

679 sgn_color = self._sgn_colors[sgn] 

680 if sgn_color in self._node_compatibility: 

681 gn_color = self._node_compatibility[sgn_color] 

682 candidates[sgn].add(frozenset(self._gn_partitions[gn_color])) 

683 else: 

684 candidates[sgn].add(frozenset()) 

685 candidates = dict(candidates) 

686 for sgn, options in candidates.items(): 

687 candidates[sgn] = frozenset(options) 

688 return candidates 

689 

690 @staticmethod 

691 def _make_constraints(cosets): 

692 """ 

693 Turn cosets into constraints. 

694 """ 

695 constraints = [] 

696 for node_i, node_ts in cosets.items(): 

697 for node_t in node_ts: 

698 if node_i != node_t: 

699 # Node i must be smaller than node t. 

700 constraints.append((node_i, node_t)) 

701 return constraints 

702 

703 @staticmethod 

704 def _find_node_edge_color(graph, node_colors, edge_colors): 

705 """ 

706 For every node in graph, come up with a color that combines 1) the 

707 color of the node, and 2) the number of edges of a color to each type 

708 of node. 

709 """ 

710 counts = defaultdict(lambda: defaultdict(int)) 

711 for node1, node2 in graph.edges: 

712 if (node1, node2) in edge_colors: 

713 # FIXME directed graphs 

714 ecolor = edge_colors[node1, node2] 

715 else: 

716 ecolor = edge_colors[node2, node1] 

717 # Count per node how many edges it has of what color to nodes of 

718 # what color 

719 counts[node1][ecolor, node_colors[node2]] += 1 

720 counts[node2][ecolor, node_colors[node1]] += 1 

721 

722 node_edge_colors = {} 

723 for node in graph.nodes: 

724 node_edge_colors[node] = node_colors[node], set(counts[node].items()) 

725 

726 return node_edge_colors 

727 

728 @staticmethod 

729 def _get_permutations_by_length(items): 

730 """ 

731 Get all permutations of items, but only permute items with the same 

732 length. 

733 

734 >>> found = list(ISMAGS._get_permutations_by_length([[1], [2], [3, 4], [4, 5]])) 

735 >>> answer = [ 

736 ... (([1], [2]), ([3, 4], [4, 5])), 

737 ... (([1], [2]), ([4, 5], [3, 4])), 

738 ... (([2], [1]), ([3, 4], [4, 5])), 

739 ... (([2], [1]), ([4, 5], [3, 4])), 

740 ... ] 

741 >>> found == answer 

742 True 

743 """ 

744 by_len = defaultdict(list) 

745 for item in items: 

746 by_len[len(item)].append(item) 

747 

748 yield from itertools.product( 

749 *(itertools.permutations(by_len[l]) for l in sorted(by_len)) 

750 ) 

751 

752 @classmethod 

753 def _refine_node_partitions(cls, graph, node_partitions, edge_colors, branch=False): 

754 """ 

755 Given a partition of nodes in graph, make the partitions smaller such 

756 that all nodes in a partition have 1) the same color, and 2) the same 

757 number of edges to specific other partitions. 

758 """ 

759 

760 def equal_color(node1, node2): 

761 return node_edge_colors[node1] == node_edge_colors[node2] 

762 

763 node_partitions = list(node_partitions) 

764 node_colors = partition_to_color(node_partitions) 

765 node_edge_colors = cls._find_node_edge_color(graph, node_colors, edge_colors) 

766 if all( 

767 are_all_equal(node_edge_colors[node] for node in partition) 

768 for partition in node_partitions 

769 ): 

770 yield node_partitions 

771 return 

772 

773 new_partitions = [] 

774 output = [new_partitions] 

775 for partition in node_partitions: 

776 if not are_all_equal(node_edge_colors[node] for node in partition): 

777 refined = make_partitions(partition, equal_color) 

778 if ( 

779 branch 

780 and len(refined) != 1 

781 and len({len(r) for r in refined}) != len([len(r) for r in refined]) 

782 ): 

783 # This is where it breaks. There are multiple new cells 

784 # in refined with the same length, and their order 

785 # matters. 

786 # So option 1) Hit it with a big hammer and simply make all 

787 # orderings. 

788 permutations = cls._get_permutations_by_length(refined) 

789 new_output = [] 

790 for n_p in output: 

791 for permutation in permutations: 

792 new_output.append(n_p + list(permutation[0])) 

793 output = new_output 

794 else: 

795 for n_p in output: 

796 n_p.extend(sorted(refined, key=len)) 

797 else: 

798 for n_p in output: 

799 n_p.append(partition) 

800 for n_p in output: 

801 yield from cls._refine_node_partitions(graph, n_p, edge_colors, branch) 

802 

803 def _edges_of_same_color(self, sgn1, sgn2): 

804 """ 

805 Returns all edges in :attr:`graph` that have the same colour as the 

806 edge between sgn1 and sgn2 in :attr:`subgraph`. 

807 """ 

808 if (sgn1, sgn2) in self._sge_colors: 

809 # FIXME directed graphs 

810 sge_color = self._sge_colors[sgn1, sgn2] 

811 else: 

812 sge_color = self._sge_colors[sgn2, sgn1] 

813 if sge_color in self._edge_compatibility: 

814 ge_color = self._edge_compatibility[sge_color] 

815 g_edges = self._ge_partitions[ge_color] 

816 else: 

817 g_edges = [] 

818 return g_edges 

819 

820 def _map_nodes(self, sgn, candidates, constraints, mapping=None, to_be_mapped=None): 

821 """ 

822 Find all subgraph isomorphisms honoring constraints. 

823 """ 

824 if mapping is None: 

825 mapping = {} 

826 else: 

827 mapping = mapping.copy() 

828 if to_be_mapped is None: 

829 to_be_mapped = set(self.subgraph.nodes) 

830 

831 # Note, we modify candidates here. Doesn't seem to affect results, but 

832 # remember this. 

833 # candidates = candidates.copy() 

834 sgn_candidates = intersect(candidates[sgn]) 

835 candidates[sgn] = frozenset([sgn_candidates]) 

836 for gn in sgn_candidates: 

837 # We're going to try to map sgn to gn. 

838 if gn in mapping.values() or sgn not in to_be_mapped: 

839 # gn is already mapped to something 

840 continue # pragma: no cover 

841 

842 # REDUCTION and COMBINATION 

843 mapping[sgn] = gn 

844 # BASECASE 

845 if to_be_mapped == set(mapping.keys()): 

846 yield {v: k for k, v in mapping.items()} 

847 continue 

848 left_to_map = to_be_mapped - set(mapping.keys()) 

849 

850 new_candidates = candidates.copy() 

851 sgn_nbrs = set(self.subgraph[sgn]) 

852 not_gn_nbrs = set(self.graph.nodes) - set(self.graph[gn]) 

853 for sgn2 in left_to_map: 

854 if sgn2 not in sgn_nbrs: 

855 gn2_options = not_gn_nbrs 

856 else: 

857 # Get all edges to gn of the right color: 

858 g_edges = self._edges_of_same_color(sgn, sgn2) 

859 # FIXME directed graphs 

860 # And all nodes involved in those which are connected to gn 

861 gn2_options = {n for e in g_edges for n in e if gn in e} 

862 # Node color compatibility should be taken care of by the 

863 # initial candidate lists made by find_subgraphs 

864 

865 # Add gn2_options to the right collection. Since new_candidates 

866 # is a dict of frozensets of frozensets of node indices it's 

867 # a bit clunky. We can't do .add, and + also doesn't work. We 

868 # could do |, but I deem union to be clearer. 

869 new_candidates[sgn2] = new_candidates[sgn2].union( 

870 [frozenset(gn2_options)] 

871 ) 

872 

873 if (sgn, sgn2) in constraints: 

874 gn2_options = {gn2 for gn2 in self.graph if gn2 > gn} 

875 elif (sgn2, sgn) in constraints: 

876 gn2_options = {gn2 for gn2 in self.graph if gn2 < gn} 

877 else: 

878 continue # pragma: no cover 

879 new_candidates[sgn2] = new_candidates[sgn2].union( 

880 [frozenset(gn2_options)] 

881 ) 

882 

883 # The next node is the one that is unmapped and has fewest 

884 # candidates 

885 next_sgn = min(left_to_map, key=lambda n: min(new_candidates[n], key=len)) 

886 yield from self._map_nodes( 

887 next_sgn, 

888 new_candidates, 

889 constraints, 

890 mapping=mapping, 

891 to_be_mapped=to_be_mapped, 

892 ) 

893 # Unmap sgn-gn. Strictly not necessary since it'd get overwritten 

894 # when making a new mapping for sgn. 

895 # del mapping[sgn] 

896 

897 def _largest_common_subgraph(self, candidates, constraints, to_be_mapped=None): 

898 """ 

899 Find all largest common subgraphs honoring constraints. 

900 """ 

901 if to_be_mapped is None: 

902 to_be_mapped = {frozenset(self.subgraph.nodes)} 

903 

904 # The LCS problem is basically a repeated subgraph isomorphism problem 

905 # with smaller and smaller subgraphs. We store the nodes that are 

906 # "part of" the subgraph in to_be_mapped, and we make it a little 

907 # smaller every iteration. 

908 

909 current_size = len(next(iter(to_be_mapped), [])) 

910 

911 found_iso = False 

912 if current_size <= len(self.graph): 

913 # There's no point in trying to find isomorphisms of 

914 # graph >= subgraph if subgraph has more nodes than graph. 

915 

916 # Try the isomorphism first with the nodes with lowest ID. So sort 

917 # them. Those are more likely to be part of the final 

918 # correspondence. This makes finding the first answer(s) faster. In 

919 # theory. 

920 for nodes in sorted(to_be_mapped, key=sorted): 

921 # Find the isomorphism between subgraph[to_be_mapped] <= graph 

922 next_sgn = min(nodes, key=lambda n: min(candidates[n], key=len)) 

923 isomorphs = self._map_nodes( 

924 next_sgn, candidates, constraints, to_be_mapped=nodes 

925 ) 

926 

927 # This is effectively `yield from isomorphs`, except that we look 

928 # whether an item was yielded. 

929 try: 

930 item = next(isomorphs) 

931 except StopIteration: 

932 pass 

933 else: 

934 yield item 

935 yield from isomorphs 

936 found_iso = True 

937 

938 # BASECASE 

939 if found_iso or current_size == 1: 

940 # Shrinking has no point because either 1) we end up with a smaller 

941 # common subgraph (and we want the largest), or 2) there'll be no 

942 # more subgraph. 

943 return 

944 

945 left_to_be_mapped = set() 

946 for nodes in to_be_mapped: 

947 for sgn in nodes: 

948 # We're going to remove sgn from to_be_mapped, but subject to 

949 # symmetry constraints. We know that for every constraint we 

950 # have those subgraph nodes are equal. So whenever we would 

951 # remove the lower part of a constraint, remove the higher 

952 # instead. This is all dealth with by _remove_node. And because 

953 # left_to_be_mapped is a set, we don't do double work. 

954 

955 # And finally, make the subgraph one node smaller. 

956 # REDUCTION 

957 new_nodes = self._remove_node(sgn, nodes, constraints) 

958 left_to_be_mapped.add(new_nodes) 

959 # COMBINATION 

960 yield from self._largest_common_subgraph( 

961 candidates, constraints, to_be_mapped=left_to_be_mapped 

962 ) 

963 

964 @staticmethod 

965 def _remove_node(node, nodes, constraints): 

966 """ 

967 Returns a new set where node has been removed from nodes, subject to 

968 symmetry constraints. We know, that for every constraint we have 

969 those subgraph nodes are equal. So whenever we would remove the 

970 lower part of a constraint, remove the higher instead. 

971 """ 

972 while True: 

973 for low, high in constraints: 

974 if low == node and high in nodes: 

975 node = high 

976 break 

977 else: # no break, couldn't find node in constraints 

978 break 

979 return frozenset(nodes - {node}) 

980 

981 @staticmethod 

982 def _find_permutations(top_partitions, bottom_partitions): 

983 """ 

984 Return the pairs of top/bottom partitions where the partitions are 

985 different. Ensures that all partitions in both top and bottom 

986 partitions have size 1. 

987 """ 

988 # Find permutations 

989 permutations = set() 

990 for top, bot in zip(top_partitions, bottom_partitions): 

991 # top and bot have only one element 

992 if len(top) != 1 or len(bot) != 1: 

993 raise IndexError( 

994 "Not all nodes are coupled. This is" 

995 f" impossible: {top_partitions}, {bottom_partitions}" 

996 ) 

997 if top != bot: 

998 permutations.add(frozenset((next(iter(top)), next(iter(bot))))) 

999 return permutations 

1000 

1001 @staticmethod 

1002 def _update_orbits(orbits, permutations): 

1003 """ 

1004 Update orbits based on permutations. Orbits is modified in place. 

1005 For every pair of items in permutations their respective orbits are 

1006 merged. 

1007 """ 

1008 for permutation in permutations: 

1009 node, node2 = permutation 

1010 # Find the orbits that contain node and node2, and replace the 

1011 # orbit containing node with the union 

1012 first = second = None 

1013 for idx, orbit in enumerate(orbits): 

1014 if first is not None and second is not None: 

1015 break 

1016 if node in orbit: 

1017 first = idx 

1018 if node2 in orbit: 

1019 second = idx 

1020 if first != second: 

1021 orbits[first].update(orbits[second]) 

1022 del orbits[second] 

1023 

1024 def _couple_nodes( 

1025 self, 

1026 top_partitions, 

1027 bottom_partitions, 

1028 pair_idx, 

1029 t_node, 

1030 b_node, 

1031 graph, 

1032 edge_colors, 

1033 ): 

1034 """ 

1035 Generate new partitions from top and bottom_partitions where t_node is 

1036 coupled to b_node. pair_idx is the index of the partitions where t_ and 

1037 b_node can be found. 

1038 """ 

1039 t_partition = top_partitions[pair_idx] 

1040 b_partition = bottom_partitions[pair_idx] 

1041 assert t_node in t_partition and b_node in b_partition 

1042 # Couple node to node2. This means they get their own partition 

1043 new_top_partitions = [top.copy() for top in top_partitions] 

1044 new_bottom_partitions = [bot.copy() for bot in bottom_partitions] 

1045 new_t_groups = {t_node}, t_partition - {t_node} 

1046 new_b_groups = {b_node}, b_partition - {b_node} 

1047 # Replace the old partitions with the coupled ones 

1048 del new_top_partitions[pair_idx] 

1049 del new_bottom_partitions[pair_idx] 

1050 new_top_partitions[pair_idx:pair_idx] = new_t_groups 

1051 new_bottom_partitions[pair_idx:pair_idx] = new_b_groups 

1052 

1053 new_top_partitions = self._refine_node_partitions( 

1054 graph, new_top_partitions, edge_colors 

1055 ) 

1056 new_bottom_partitions = self._refine_node_partitions( 

1057 graph, new_bottom_partitions, edge_colors, branch=True 

1058 ) 

1059 new_top_partitions = list(new_top_partitions) 

1060 assert len(new_top_partitions) == 1 

1061 new_top_partitions = new_top_partitions[0] 

1062 for bot in new_bottom_partitions: 

1063 yield list(new_top_partitions), bot 

1064 

1065 def _process_ordered_pair_partitions( 

1066 self, 

1067 graph, 

1068 top_partitions, 

1069 bottom_partitions, 

1070 edge_colors, 

1071 orbits=None, 

1072 cosets=None, 

1073 ): 

1074 """ 

1075 Processes ordered pair partitions as per the reference paper. Finds and 

1076 returns all permutations and cosets that leave the graph unchanged. 

1077 """ 

1078 if orbits is None: 

1079 orbits = [{node} for node in graph.nodes] 

1080 else: 

1081 # Note that we don't copy orbits when we are given one. This means 

1082 # we leak information between the recursive branches. This is 

1083 # intentional! 

1084 orbits = orbits 

1085 if cosets is None: 

1086 cosets = {} 

1087 else: 

1088 cosets = cosets.copy() 

1089 

1090 if not all( 

1091 len(t_p) == len(b_p) for t_p, b_p in zip(top_partitions, bottom_partitions) 

1092 ): 

1093 # This used to be an assertion, but it gets tripped in rare cases: 

1094 # 5 - 4 \ / 12 - 13 

1095 # 0 - 3 

1096 # 9 - 8 / \ 16 - 17 

1097 # Assume 0 and 3 are coupled and no longer equivalent. At that point 

1098 # {4, 8} and {12, 16} are no longer equivalent, and neither are 

1099 # {5, 9} and {13, 17}. Coupling 4 and refinement results in 5 and 9 

1100 # getting their own partitions, *but not 13 and 17*. Further 

1101 # iterations will attempt to couple 5 to {13, 17}, which cannot 

1102 # result in more symmetries? 

1103 return [], cosets 

1104 

1105 # BASECASE 

1106 if all(len(top) == 1 for top in top_partitions): 

1107 # All nodes are mapped 

1108 permutations = self._find_permutations(top_partitions, bottom_partitions) 

1109 self._update_orbits(orbits, permutations) 

1110 if permutations: 

1111 return [permutations], cosets 

1112 else: 

1113 return [], cosets 

1114 

1115 permutations = [] 

1116 unmapped_nodes = { 

1117 (node, idx) 

1118 for idx, t_partition in enumerate(top_partitions) 

1119 for node in t_partition 

1120 if len(t_partition) > 1 

1121 } 

1122 node, pair_idx = min(unmapped_nodes) 

1123 b_partition = bottom_partitions[pair_idx] 

1124 

1125 for node2 in sorted(b_partition): 

1126 if len(b_partition) == 1: 

1127 # Can never result in symmetry 

1128 continue 

1129 if node != node2 and any( 

1130 node in orbit and node2 in orbit for orbit in orbits 

1131 ): 

1132 # Orbit prune branch 

1133 continue 

1134 # REDUCTION 

1135 # Couple node to node2 

1136 partitions = self._couple_nodes( 

1137 top_partitions, 

1138 bottom_partitions, 

1139 pair_idx, 

1140 node, 

1141 node2, 

1142 graph, 

1143 edge_colors, 

1144 ) 

1145 for opp in partitions: 

1146 new_top_partitions, new_bottom_partitions = opp 

1147 

1148 new_perms, new_cosets = self._process_ordered_pair_partitions( 

1149 graph, 

1150 new_top_partitions, 

1151 new_bottom_partitions, 

1152 edge_colors, 

1153 orbits, 

1154 cosets, 

1155 ) 

1156 # COMBINATION 

1157 permutations += new_perms 

1158 cosets.update(new_cosets) 

1159 

1160 mapped = { 

1161 k 

1162 for top, bottom in zip(top_partitions, bottom_partitions) 

1163 for k in top 

1164 if len(top) == 1 and top == bottom 

1165 } 

1166 ks = {k for k in graph.nodes if k < node} 

1167 # Have all nodes with ID < node been mapped? 

1168 find_coset = ks <= mapped and node not in cosets 

1169 if find_coset: 

1170 # Find the orbit that contains node 

1171 for orbit in orbits: 

1172 if node in orbit: 

1173 cosets[node] = orbit.copy() 

1174 return permutations, cosets