layout.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302
  1. """
  2. ******
  3. Layout
  4. ******
  5. Node positioning algorithms for graph drawing.
  6. For `random_layout()` the possible resulting shape
  7. is a square of side [0, scale] (default: [0, 1])
  8. Changing `center` shifts the layout by that amount.
  9. For the other layout routines, the extent is
  10. [center - scale, center + scale] (default: [-1, 1]).
  11. Warning: Most layout routines have only been tested in 2-dimensions.
  12. """
  13. import networkx as nx
  14. from networkx.utils import np_random_state
  15. __all__ = [
  16. "bipartite_layout",
  17. "circular_layout",
  18. "kamada_kawai_layout",
  19. "random_layout",
  20. "rescale_layout",
  21. "rescale_layout_dict",
  22. "shell_layout",
  23. "spring_layout",
  24. "spectral_layout",
  25. "planar_layout",
  26. "fruchterman_reingold_layout",
  27. "spiral_layout",
  28. "multipartite_layout",
  29. "arf_layout",
  30. ]
  31. def _process_params(G, center, dim):
  32. # Some boilerplate code.
  33. import numpy as np
  34. if not isinstance(G, nx.Graph):
  35. empty_graph = nx.Graph()
  36. empty_graph.add_nodes_from(G)
  37. G = empty_graph
  38. if center is None:
  39. center = np.zeros(dim)
  40. else:
  41. center = np.asarray(center)
  42. if len(center) != dim:
  43. msg = "length of center coordinates must match dimension of layout"
  44. raise ValueError(msg)
  45. return G, center
  46. @np_random_state(3)
  47. def random_layout(G, center=None, dim=2, seed=None):
  48. """Position nodes uniformly at random in the unit square.
  49. For every node, a position is generated by choosing each of dim
  50. coordinates uniformly at random on the interval [0.0, 1.0).
  51. NumPy (http://scipy.org) is required for this function.
  52. Parameters
  53. ----------
  54. G : NetworkX graph or list of nodes
  55. A position will be assigned to every node in G.
  56. center : array-like or None
  57. Coordinate pair around which to center the layout.
  58. dim : int
  59. Dimension of layout.
  60. seed : int, RandomState instance or None optional (default=None)
  61. Set the random state for deterministic node layouts.
  62. If int, `seed` is the seed used by the random number generator,
  63. if numpy.random.RandomState instance, `seed` is the random
  64. number generator,
  65. if None, the random number generator is the RandomState instance used
  66. by numpy.random.
  67. Returns
  68. -------
  69. pos : dict
  70. A dictionary of positions keyed by node
  71. Examples
  72. --------
  73. >>> G = nx.lollipop_graph(4, 3)
  74. >>> pos = nx.random_layout(G)
  75. """
  76. import numpy as np
  77. G, center = _process_params(G, center, dim)
  78. pos = seed.rand(len(G), dim) + center
  79. pos = pos.astype(np.float32)
  80. pos = dict(zip(G, pos))
  81. return pos
  82. def circular_layout(G, scale=1, center=None, dim=2):
  83. # dim=2 only
  84. """Position nodes on a circle.
  85. Parameters
  86. ----------
  87. G : NetworkX graph or list of nodes
  88. A position will be assigned to every node in G.
  89. scale : number (default: 1)
  90. Scale factor for positions.
  91. center : array-like or None
  92. Coordinate pair around which to center the layout.
  93. dim : int
  94. Dimension of layout.
  95. If dim>2, the remaining dimensions are set to zero
  96. in the returned positions.
  97. If dim<2, a ValueError is raised.
  98. Returns
  99. -------
  100. pos : dict
  101. A dictionary of positions keyed by node
  102. Raises
  103. ------
  104. ValueError
  105. If dim < 2
  106. Examples
  107. --------
  108. >>> G = nx.path_graph(4)
  109. >>> pos = nx.circular_layout(G)
  110. Notes
  111. -----
  112. This algorithm currently only works in two dimensions and does not
  113. try to minimize edge crossings.
  114. """
  115. import numpy as np
  116. if dim < 2:
  117. raise ValueError("cannot handle dimensions < 2")
  118. G, center = _process_params(G, center, dim)
  119. paddims = max(0, (dim - 2))
  120. if len(G) == 0:
  121. pos = {}
  122. elif len(G) == 1:
  123. pos = {nx.utils.arbitrary_element(G): center}
  124. else:
  125. # Discard the extra angle since it matches 0 radians.
  126. theta = np.linspace(0, 1, len(G) + 1)[:-1] * 2 * np.pi
  127. theta = theta.astype(np.float32)
  128. pos = np.column_stack(
  129. [np.cos(theta), np.sin(theta), np.zeros((len(G), paddims))]
  130. )
  131. pos = rescale_layout(pos, scale=scale) + center
  132. pos = dict(zip(G, pos))
  133. return pos
  134. def shell_layout(G, nlist=None, rotate=None, scale=1, center=None, dim=2):
  135. """Position nodes in concentric circles.
  136. Parameters
  137. ----------
  138. G : NetworkX graph or list of nodes
  139. A position will be assigned to every node in G.
  140. nlist : list of lists
  141. List of node lists for each shell.
  142. rotate : angle in radians (default=pi/len(nlist))
  143. Angle by which to rotate the starting position of each shell
  144. relative to the starting position of the previous shell.
  145. To recreate behavior before v2.5 use rotate=0.
  146. scale : number (default: 1)
  147. Scale factor for positions.
  148. center : array-like or None
  149. Coordinate pair around which to center the layout.
  150. dim : int
  151. Dimension of layout, currently only dim=2 is supported.
  152. Other dimension values result in a ValueError.
  153. Returns
  154. -------
  155. pos : dict
  156. A dictionary of positions keyed by node
  157. Raises
  158. ------
  159. ValueError
  160. If dim != 2
  161. Examples
  162. --------
  163. >>> G = nx.path_graph(4)
  164. >>> shells = [[0], [1, 2, 3]]
  165. >>> pos = nx.shell_layout(G, shells)
  166. Notes
  167. -----
  168. This algorithm currently only works in two dimensions and does not
  169. try to minimize edge crossings.
  170. """
  171. import numpy as np
  172. if dim != 2:
  173. raise ValueError("can only handle 2 dimensions")
  174. G, center = _process_params(G, center, dim)
  175. if len(G) == 0:
  176. return {}
  177. if len(G) == 1:
  178. return {nx.utils.arbitrary_element(G): center}
  179. if nlist is None:
  180. # draw the whole graph in one shell
  181. nlist = [list(G)]
  182. radius_bump = scale / len(nlist)
  183. if len(nlist[0]) == 1:
  184. # single node at center
  185. radius = 0.0
  186. else:
  187. # else start at r=1
  188. radius = radius_bump
  189. if rotate is None:
  190. rotate = np.pi / len(nlist)
  191. first_theta = rotate
  192. npos = {}
  193. for nodes in nlist:
  194. # Discard the last angle (endpoint=False) since 2*pi matches 0 radians
  195. theta = (
  196. np.linspace(0, 2 * np.pi, len(nodes), endpoint=False, dtype=np.float32)
  197. + first_theta
  198. )
  199. pos = radius * np.column_stack([np.cos(theta), np.sin(theta)]) + center
  200. npos.update(zip(nodes, pos))
  201. radius += radius_bump
  202. first_theta += rotate
  203. return npos
  204. def bipartite_layout(
  205. G, nodes, align="vertical", scale=1, center=None, aspect_ratio=4 / 3
  206. ):
  207. """Position nodes in two straight lines.
  208. Parameters
  209. ----------
  210. G : NetworkX graph or list of nodes
  211. A position will be assigned to every node in G.
  212. nodes : list or container
  213. Nodes in one node set of the bipartite graph.
  214. This set will be placed on left or top.
  215. align : string (default='vertical')
  216. The alignment of nodes. Vertical or horizontal.
  217. scale : number (default: 1)
  218. Scale factor for positions.
  219. center : array-like or None
  220. Coordinate pair around which to center the layout.
  221. aspect_ratio : number (default=4/3):
  222. The ratio of the width to the height of the layout.
  223. Returns
  224. -------
  225. pos : dict
  226. A dictionary of positions keyed by node.
  227. Examples
  228. --------
  229. >>> G = nx.bipartite.gnmk_random_graph(3, 5, 10, seed=123)
  230. >>> top = nx.bipartite.sets(G)[0]
  231. >>> pos = nx.bipartite_layout(G, top)
  232. Notes
  233. -----
  234. This algorithm currently only works in two dimensions and does not
  235. try to minimize edge crossings.
  236. """
  237. import numpy as np
  238. if align not in ("vertical", "horizontal"):
  239. msg = "align must be either vertical or horizontal."
  240. raise ValueError(msg)
  241. G, center = _process_params(G, center=center, dim=2)
  242. if len(G) == 0:
  243. return {}
  244. height = 1
  245. width = aspect_ratio * height
  246. offset = (width / 2, height / 2)
  247. top = set(nodes)
  248. bottom = set(G) - top
  249. nodes = list(top) + list(bottom)
  250. left_xs = np.repeat(0, len(top))
  251. right_xs = np.repeat(width, len(bottom))
  252. left_ys = np.linspace(0, height, len(top))
  253. right_ys = np.linspace(0, height, len(bottom))
  254. top_pos = np.column_stack([left_xs, left_ys]) - offset
  255. bottom_pos = np.column_stack([right_xs, right_ys]) - offset
  256. pos = np.concatenate([top_pos, bottom_pos])
  257. pos = rescale_layout(pos, scale=scale) + center
  258. if align == "horizontal":
  259. pos = pos[:, ::-1] # swap x and y coords
  260. pos = dict(zip(nodes, pos))
  261. return pos
  262. @np_random_state(10)
  263. def spring_layout(
  264. G,
  265. k=None,
  266. pos=None,
  267. fixed=None,
  268. iterations=50,
  269. threshold=1e-4,
  270. weight="weight",
  271. scale=1,
  272. center=None,
  273. dim=2,
  274. seed=None,
  275. ):
  276. """Position nodes using Fruchterman-Reingold force-directed algorithm.
  277. The algorithm simulates a force-directed representation of the network
  278. treating edges as springs holding nodes close, while treating nodes
  279. as repelling objects, sometimes called an anti-gravity force.
  280. Simulation continues until the positions are close to an equilibrium.
  281. There are some hard-coded values: minimal distance between
  282. nodes (0.01) and "temperature" of 0.1 to ensure nodes don't fly away.
  283. During the simulation, `k` helps determine the distance between nodes,
  284. though `scale` and `center` determine the size and place after
  285. rescaling occurs at the end of the simulation.
  286. Fixing some nodes doesn't allow them to move in the simulation.
  287. It also turns off the rescaling feature at the simulation's end.
  288. In addition, setting `scale` to `None` turns off rescaling.
  289. Parameters
  290. ----------
  291. G : NetworkX graph or list of nodes
  292. A position will be assigned to every node in G.
  293. k : float (default=None)
  294. Optimal distance between nodes. If None the distance is set to
  295. 1/sqrt(n) where n is the number of nodes. Increase this value
  296. to move nodes farther apart.
  297. pos : dict or None optional (default=None)
  298. Initial positions for nodes as a dictionary with node as keys
  299. and values as a coordinate list or tuple. If None, then use
  300. random initial positions.
  301. fixed : list or None optional (default=None)
  302. Nodes to keep fixed at initial position.
  303. Nodes not in ``G.nodes`` are ignored.
  304. ValueError raised if `fixed` specified and `pos` not.
  305. iterations : int optional (default=50)
  306. Maximum number of iterations taken
  307. threshold: float optional (default = 1e-4)
  308. Threshold for relative error in node position changes.
  309. The iteration stops if the error is below this threshold.
  310. weight : string or None optional (default='weight')
  311. The edge attribute that holds the numerical value used for
  312. the edge weight. Larger means a stronger attractive force.
  313. If None, then all edge weights are 1.
  314. scale : number or None (default: 1)
  315. Scale factor for positions. Not used unless `fixed is None`.
  316. If scale is None, no rescaling is performed.
  317. center : array-like or None
  318. Coordinate pair around which to center the layout.
  319. Not used unless `fixed is None`.
  320. dim : int
  321. Dimension of layout.
  322. seed : int, RandomState instance or None optional (default=None)
  323. Set the random state for deterministic node layouts.
  324. If int, `seed` is the seed used by the random number generator,
  325. if numpy.random.RandomState instance, `seed` is the random
  326. number generator,
  327. if None, the random number generator is the RandomState instance used
  328. by numpy.random.
  329. Returns
  330. -------
  331. pos : dict
  332. A dictionary of positions keyed by node
  333. Examples
  334. --------
  335. >>> G = nx.path_graph(4)
  336. >>> pos = nx.spring_layout(G)
  337. # The same using longer but equivalent function name
  338. >>> pos = nx.fruchterman_reingold_layout(G)
  339. """
  340. import numpy as np
  341. G, center = _process_params(G, center, dim)
  342. if fixed is not None:
  343. if pos is None:
  344. raise ValueError("nodes are fixed without positions given")
  345. for node in fixed:
  346. if node not in pos:
  347. raise ValueError("nodes are fixed without positions given")
  348. nfixed = {node: i for i, node in enumerate(G)}
  349. fixed = np.asarray([nfixed[node] for node in fixed if node in nfixed])
  350. if pos is not None:
  351. # Determine size of existing domain to adjust initial positions
  352. dom_size = max(coord for pos_tup in pos.values() for coord in pos_tup)
  353. if dom_size == 0:
  354. dom_size = 1
  355. pos_arr = seed.rand(len(G), dim) * dom_size + center
  356. for i, n in enumerate(G):
  357. if n in pos:
  358. pos_arr[i] = np.asarray(pos[n])
  359. else:
  360. pos_arr = None
  361. dom_size = 1
  362. if len(G) == 0:
  363. return {}
  364. if len(G) == 1:
  365. return {nx.utils.arbitrary_element(G.nodes()): center}
  366. try:
  367. # Sparse matrix
  368. if len(G) < 500: # sparse solver for large graphs
  369. raise ValueError
  370. A = nx.to_scipy_sparse_array(G, weight=weight, dtype="f")
  371. if k is None and fixed is not None:
  372. # We must adjust k by domain size for layouts not near 1x1
  373. nnodes, _ = A.shape
  374. k = dom_size / np.sqrt(nnodes)
  375. pos = _sparse_fruchterman_reingold(
  376. A, k, pos_arr, fixed, iterations, threshold, dim, seed
  377. )
  378. except ValueError:
  379. A = nx.to_numpy_array(G, weight=weight)
  380. if k is None and fixed is not None:
  381. # We must adjust k by domain size for layouts not near 1x1
  382. nnodes, _ = A.shape
  383. k = dom_size / np.sqrt(nnodes)
  384. pos = _fruchterman_reingold(
  385. A, k, pos_arr, fixed, iterations, threshold, dim, seed
  386. )
  387. if fixed is None and scale is not None:
  388. pos = rescale_layout(pos, scale=scale) + center
  389. pos = dict(zip(G, pos))
  390. return pos
  391. fruchterman_reingold_layout = spring_layout
  392. @np_random_state(7)
  393. def _fruchterman_reingold(
  394. A, k=None, pos=None, fixed=None, iterations=50, threshold=1e-4, dim=2, seed=None
  395. ):
  396. # Position nodes in adjacency matrix A using Fruchterman-Reingold
  397. # Entry point for NetworkX graph is fruchterman_reingold_layout()
  398. import numpy as np
  399. try:
  400. nnodes, _ = A.shape
  401. except AttributeError as err:
  402. msg = "fruchterman_reingold() takes an adjacency matrix as input"
  403. raise nx.NetworkXError(msg) from err
  404. if pos is None:
  405. # random initial positions
  406. pos = np.asarray(seed.rand(nnodes, dim), dtype=A.dtype)
  407. else:
  408. # make sure positions are of same type as matrix
  409. pos = pos.astype(A.dtype)
  410. # optimal distance between nodes
  411. if k is None:
  412. k = np.sqrt(1.0 / nnodes)
  413. # the initial "temperature" is about .1 of domain area (=1x1)
  414. # this is the largest step allowed in the dynamics.
  415. # We need to calculate this in case our fixed positions force our domain
  416. # to be much bigger than 1x1
  417. t = max(max(pos.T[0]) - min(pos.T[0]), max(pos.T[1]) - min(pos.T[1])) * 0.1
  418. # simple cooling scheme.
  419. # linearly step down by dt on each iteration so last iteration is size dt.
  420. dt = t / (iterations + 1)
  421. delta = np.zeros((pos.shape[0], pos.shape[0], pos.shape[1]), dtype=A.dtype)
  422. # the inscrutable (but fast) version
  423. # this is still O(V^2)
  424. # could use multilevel methods to speed this up significantly
  425. for iteration in range(iterations):
  426. # matrix of difference between points
  427. delta = pos[:, np.newaxis, :] - pos[np.newaxis, :, :]
  428. # distance between points
  429. distance = np.linalg.norm(delta, axis=-1)
  430. # enforce minimum distance of 0.01
  431. np.clip(distance, 0.01, None, out=distance)
  432. # displacement "force"
  433. displacement = np.einsum(
  434. "ijk,ij->ik", delta, (k * k / distance**2 - A * distance / k)
  435. )
  436. # update positions
  437. length = np.linalg.norm(displacement, axis=-1)
  438. length = np.where(length < 0.01, 0.1, length)
  439. delta_pos = np.einsum("ij,i->ij", displacement, t / length)
  440. if fixed is not None:
  441. # don't change positions of fixed nodes
  442. delta_pos[fixed] = 0.0
  443. pos += delta_pos
  444. # cool temperature
  445. t -= dt
  446. if (np.linalg.norm(delta_pos) / nnodes) < threshold:
  447. break
  448. return pos
  449. @np_random_state(7)
  450. def _sparse_fruchterman_reingold(
  451. A, k=None, pos=None, fixed=None, iterations=50, threshold=1e-4, dim=2, seed=None
  452. ):
  453. # Position nodes in adjacency matrix A using Fruchterman-Reingold
  454. # Entry point for NetworkX graph is fruchterman_reingold_layout()
  455. # Sparse version
  456. import numpy as np
  457. import scipy as sp
  458. import scipy.sparse # call as sp.sparse
  459. try:
  460. nnodes, _ = A.shape
  461. except AttributeError as err:
  462. msg = "fruchterman_reingold() takes an adjacency matrix as input"
  463. raise nx.NetworkXError(msg) from err
  464. # make sure we have a LIst of Lists representation
  465. try:
  466. A = A.tolil()
  467. except AttributeError:
  468. A = (sp.sparse.coo_array(A)).tolil()
  469. if pos is None:
  470. # random initial positions
  471. pos = np.asarray(seed.rand(nnodes, dim), dtype=A.dtype)
  472. else:
  473. # make sure positions are of same type as matrix
  474. pos = pos.astype(A.dtype)
  475. # no fixed nodes
  476. if fixed is None:
  477. fixed = []
  478. # optimal distance between nodes
  479. if k is None:
  480. k = np.sqrt(1.0 / nnodes)
  481. # the initial "temperature" is about .1 of domain area (=1x1)
  482. # this is the largest step allowed in the dynamics.
  483. t = max(max(pos.T[0]) - min(pos.T[0]), max(pos.T[1]) - min(pos.T[1])) * 0.1
  484. # simple cooling scheme.
  485. # linearly step down by dt on each iteration so last iteration is size dt.
  486. dt = t / (iterations + 1)
  487. displacement = np.zeros((dim, nnodes))
  488. for iteration in range(iterations):
  489. displacement *= 0
  490. # loop over rows
  491. for i in range(A.shape[0]):
  492. if i in fixed:
  493. continue
  494. # difference between this row's node position and all others
  495. delta = (pos[i] - pos).T
  496. # distance between points
  497. distance = np.sqrt((delta**2).sum(axis=0))
  498. # enforce minimum distance of 0.01
  499. distance = np.where(distance < 0.01, 0.01, distance)
  500. # the adjacency matrix row
  501. Ai = A.getrowview(i).toarray() # TODO: revisit w/ sparse 1D container
  502. # displacement "force"
  503. displacement[:, i] += (
  504. delta * (k * k / distance**2 - Ai * distance / k)
  505. ).sum(axis=1)
  506. # update positions
  507. length = np.sqrt((displacement**2).sum(axis=0))
  508. length = np.where(length < 0.01, 0.1, length)
  509. delta_pos = (displacement * t / length).T
  510. pos += delta_pos
  511. # cool temperature
  512. t -= dt
  513. if (np.linalg.norm(delta_pos) / nnodes) < threshold:
  514. break
  515. return pos
  516. def kamada_kawai_layout(
  517. G, dist=None, pos=None, weight="weight", scale=1, center=None, dim=2
  518. ):
  519. """Position nodes using Kamada-Kawai path-length cost-function.
  520. Parameters
  521. ----------
  522. G : NetworkX graph or list of nodes
  523. A position will be assigned to every node in G.
  524. dist : dict (default=None)
  525. A two-level dictionary of optimal distances between nodes,
  526. indexed by source and destination node.
  527. If None, the distance is computed using shortest_path_length().
  528. pos : dict or None optional (default=None)
  529. Initial positions for nodes as a dictionary with node as keys
  530. and values as a coordinate list or tuple. If None, then use
  531. circular_layout() for dim >= 2 and a linear layout for dim == 1.
  532. weight : string or None optional (default='weight')
  533. The edge attribute that holds the numerical value used for
  534. the edge weight. If None, then all edge weights are 1.
  535. scale : number (default: 1)
  536. Scale factor for positions.
  537. center : array-like or None
  538. Coordinate pair around which to center the layout.
  539. dim : int
  540. Dimension of layout.
  541. Returns
  542. -------
  543. pos : dict
  544. A dictionary of positions keyed by node
  545. Examples
  546. --------
  547. >>> G = nx.path_graph(4)
  548. >>> pos = nx.kamada_kawai_layout(G)
  549. """
  550. import numpy as np
  551. G, center = _process_params(G, center, dim)
  552. nNodes = len(G)
  553. if nNodes == 0:
  554. return {}
  555. if dist is None:
  556. dist = dict(nx.shortest_path_length(G, weight=weight))
  557. dist_mtx = 1e6 * np.ones((nNodes, nNodes))
  558. for row, nr in enumerate(G):
  559. if nr not in dist:
  560. continue
  561. rdist = dist[nr]
  562. for col, nc in enumerate(G):
  563. if nc not in rdist:
  564. continue
  565. dist_mtx[row][col] = rdist[nc]
  566. if pos is None:
  567. if dim >= 3:
  568. pos = random_layout(G, dim=dim)
  569. elif dim == 2:
  570. pos = circular_layout(G, dim=dim)
  571. else:
  572. pos = dict(zip(G, np.linspace(0, 1, len(G))))
  573. pos_arr = np.array([pos[n] for n in G])
  574. pos = _kamada_kawai_solve(dist_mtx, pos_arr, dim)
  575. pos = rescale_layout(pos, scale=scale) + center
  576. return dict(zip(G, pos))
  577. def _kamada_kawai_solve(dist_mtx, pos_arr, dim):
  578. # Anneal node locations based on the Kamada-Kawai cost-function,
  579. # using the supplied matrix of preferred inter-node distances,
  580. # and starting locations.
  581. import numpy as np
  582. import scipy as sp
  583. import scipy.optimize # call as sp.optimize
  584. meanwt = 1e-3
  585. costargs = (np, 1 / (dist_mtx + np.eye(dist_mtx.shape[0]) * 1e-3), meanwt, dim)
  586. optresult = sp.optimize.minimize(
  587. _kamada_kawai_costfn,
  588. pos_arr.ravel(),
  589. method="L-BFGS-B",
  590. args=costargs,
  591. jac=True,
  592. )
  593. return optresult.x.reshape((-1, dim))
  594. def _kamada_kawai_costfn(pos_vec, np, invdist, meanweight, dim):
  595. # Cost-function and gradient for Kamada-Kawai layout algorithm
  596. nNodes = invdist.shape[0]
  597. pos_arr = pos_vec.reshape((nNodes, dim))
  598. delta = pos_arr[:, np.newaxis, :] - pos_arr[np.newaxis, :, :]
  599. nodesep = np.linalg.norm(delta, axis=-1)
  600. direction = np.einsum("ijk,ij->ijk", delta, 1 / (nodesep + np.eye(nNodes) * 1e-3))
  601. offset = nodesep * invdist - 1.0
  602. offset[np.diag_indices(nNodes)] = 0
  603. cost = 0.5 * np.sum(offset**2)
  604. grad = np.einsum("ij,ij,ijk->ik", invdist, offset, direction) - np.einsum(
  605. "ij,ij,ijk->jk", invdist, offset, direction
  606. )
  607. # Additional parabolic term to encourage mean position to be near origin:
  608. sumpos = np.sum(pos_arr, axis=0)
  609. cost += 0.5 * meanweight * np.sum(sumpos**2)
  610. grad += meanweight * sumpos
  611. return (cost, grad.ravel())
  612. def spectral_layout(G, weight="weight", scale=1, center=None, dim=2):
  613. """Position nodes using the eigenvectors of the graph Laplacian.
  614. Using the unnormalized Laplacian, the layout shows possible clusters of
  615. nodes which are an approximation of the ratio cut. If dim is the number of
  616. dimensions then the positions are the entries of the dim eigenvectors
  617. corresponding to the ascending eigenvalues starting from the second one.
  618. Parameters
  619. ----------
  620. G : NetworkX graph or list of nodes
  621. A position will be assigned to every node in G.
  622. weight : string or None optional (default='weight')
  623. The edge attribute that holds the numerical value used for
  624. the edge weight. If None, then all edge weights are 1.
  625. scale : number (default: 1)
  626. Scale factor for positions.
  627. center : array-like or None
  628. Coordinate pair around which to center the layout.
  629. dim : int
  630. Dimension of layout.
  631. Returns
  632. -------
  633. pos : dict
  634. A dictionary of positions keyed by node
  635. Examples
  636. --------
  637. >>> G = nx.path_graph(4)
  638. >>> pos = nx.spectral_layout(G)
  639. Notes
  640. -----
  641. Directed graphs will be considered as undirected graphs when
  642. positioning the nodes.
  643. For larger graphs (>500 nodes) this will use the SciPy sparse
  644. eigenvalue solver (ARPACK).
  645. """
  646. # handle some special cases that break the eigensolvers
  647. import numpy as np
  648. G, center = _process_params(G, center, dim)
  649. if len(G) <= 2:
  650. if len(G) == 0:
  651. pos = np.array([])
  652. elif len(G) == 1:
  653. pos = np.array([center])
  654. else:
  655. pos = np.array([np.zeros(dim), np.array(center) * 2.0])
  656. return dict(zip(G, pos))
  657. try:
  658. # Sparse matrix
  659. if len(G) < 500: # dense solver is faster for small graphs
  660. raise ValueError
  661. A = nx.to_scipy_sparse_array(G, weight=weight, dtype="d")
  662. # Symmetrize directed graphs
  663. if G.is_directed():
  664. A = A + np.transpose(A)
  665. pos = _sparse_spectral(A, dim)
  666. except (ImportError, ValueError):
  667. # Dense matrix
  668. A = nx.to_numpy_array(G, weight=weight)
  669. # Symmetrize directed graphs
  670. if G.is_directed():
  671. A += A.T
  672. pos = _spectral(A, dim)
  673. pos = rescale_layout(pos, scale=scale) + center
  674. pos = dict(zip(G, pos))
  675. return pos
  676. def _spectral(A, dim=2):
  677. # Input adjacency matrix A
  678. # Uses dense eigenvalue solver from numpy
  679. import numpy as np
  680. try:
  681. nnodes, _ = A.shape
  682. except AttributeError as err:
  683. msg = "spectral() takes an adjacency matrix as input"
  684. raise nx.NetworkXError(msg) from err
  685. # form Laplacian matrix where D is diagonal of degrees
  686. D = np.identity(nnodes, dtype=A.dtype) * np.sum(A, axis=1)
  687. L = D - A
  688. eigenvalues, eigenvectors = np.linalg.eig(L)
  689. # sort and keep smallest nonzero
  690. index = np.argsort(eigenvalues)[1 : dim + 1] # 0 index is zero eigenvalue
  691. return np.real(eigenvectors[:, index])
  692. def _sparse_spectral(A, dim=2):
  693. # Input adjacency matrix A
  694. # Uses sparse eigenvalue solver from scipy
  695. # Could use multilevel methods here, see Koren "On spectral graph drawing"
  696. import numpy as np
  697. import scipy as sp
  698. import scipy.sparse # call as sp.sparse
  699. import scipy.sparse.linalg # call as sp.sparse.linalg
  700. try:
  701. nnodes, _ = A.shape
  702. except AttributeError as err:
  703. msg = "sparse_spectral() takes an adjacency matrix as input"
  704. raise nx.NetworkXError(msg) from err
  705. # form Laplacian matrix
  706. # TODO: Rm csr_array wrapper in favor of spdiags array constructor when available
  707. D = sp.sparse.csr_array(sp.sparse.spdiags(A.sum(axis=1), 0, nnodes, nnodes))
  708. L = D - A
  709. k = dim + 1
  710. # number of Lanczos vectors for ARPACK solver.What is the right scaling?
  711. ncv = max(2 * k + 1, int(np.sqrt(nnodes)))
  712. # return smallest k eigenvalues and eigenvectors
  713. eigenvalues, eigenvectors = sp.sparse.linalg.eigsh(L, k, which="SM", ncv=ncv)
  714. index = np.argsort(eigenvalues)[1:k] # 0 index is zero eigenvalue
  715. return np.real(eigenvectors[:, index])
  716. def planar_layout(G, scale=1, center=None, dim=2):
  717. """Position nodes without edge intersections.
  718. Parameters
  719. ----------
  720. G : NetworkX graph or list of nodes
  721. A position will be assigned to every node in G. If G is of type
  722. nx.PlanarEmbedding, the positions are selected accordingly.
  723. scale : number (default: 1)
  724. Scale factor for positions.
  725. center : array-like or None
  726. Coordinate pair around which to center the layout.
  727. dim : int
  728. Dimension of layout.
  729. Returns
  730. -------
  731. pos : dict
  732. A dictionary of positions keyed by node
  733. Raises
  734. ------
  735. NetworkXException
  736. If G is not planar
  737. Examples
  738. --------
  739. >>> G = nx.path_graph(4)
  740. >>> pos = nx.planar_layout(G)
  741. """
  742. import numpy as np
  743. if dim != 2:
  744. raise ValueError("can only handle 2 dimensions")
  745. G, center = _process_params(G, center, dim)
  746. if len(G) == 0:
  747. return {}
  748. if isinstance(G, nx.PlanarEmbedding):
  749. embedding = G
  750. else:
  751. is_planar, embedding = nx.check_planarity(G)
  752. if not is_planar:
  753. raise nx.NetworkXException("G is not planar.")
  754. pos = nx.combinatorial_embedding_to_pos(embedding)
  755. node_list = list(embedding)
  756. pos = np.row_stack([pos[x] for x in node_list])
  757. pos = pos.astype(np.float64)
  758. pos = rescale_layout(pos, scale=scale) + center
  759. return dict(zip(node_list, pos))
  760. def spiral_layout(G, scale=1, center=None, dim=2, resolution=0.35, equidistant=False):
  761. """Position nodes in a spiral layout.
  762. Parameters
  763. ----------
  764. G : NetworkX graph or list of nodes
  765. A position will be assigned to every node in G.
  766. scale : number (default: 1)
  767. Scale factor for positions.
  768. center : array-like or None
  769. Coordinate pair around which to center the layout.
  770. dim : int, default=2
  771. Dimension of layout, currently only dim=2 is supported.
  772. Other dimension values result in a ValueError.
  773. resolution : float, default=0.35
  774. The compactness of the spiral layout returned.
  775. Lower values result in more compressed spiral layouts.
  776. equidistant : bool, default=False
  777. If True, nodes will be positioned equidistant from each other
  778. by decreasing angle further from center.
  779. If False, nodes will be positioned at equal angles
  780. from each other by increasing separation further from center.
  781. Returns
  782. -------
  783. pos : dict
  784. A dictionary of positions keyed by node
  785. Raises
  786. ------
  787. ValueError
  788. If dim != 2
  789. Examples
  790. --------
  791. >>> G = nx.path_graph(4)
  792. >>> pos = nx.spiral_layout(G)
  793. >>> nx.draw(G, pos=pos)
  794. Notes
  795. -----
  796. This algorithm currently only works in two dimensions.
  797. """
  798. import numpy as np
  799. if dim != 2:
  800. raise ValueError("can only handle 2 dimensions")
  801. G, center = _process_params(G, center, dim)
  802. if len(G) == 0:
  803. return {}
  804. if len(G) == 1:
  805. return {nx.utils.arbitrary_element(G): center}
  806. pos = []
  807. if equidistant:
  808. chord = 1
  809. step = 0.5
  810. theta = resolution
  811. theta += chord / (step * theta)
  812. for _ in range(len(G)):
  813. r = step * theta
  814. theta += chord / r
  815. pos.append([np.cos(theta) * r, np.sin(theta) * r])
  816. else:
  817. dist = np.arange(len(G), dtype=float)
  818. angle = resolution * dist
  819. pos = np.transpose(dist * np.array([np.cos(angle), np.sin(angle)]))
  820. pos = rescale_layout(np.array(pos), scale=scale) + center
  821. pos = dict(zip(G, pos))
  822. return pos
  823. def multipartite_layout(G, subset_key="subset", align="vertical", scale=1, center=None):
  824. """Position nodes in layers of straight lines.
  825. Parameters
  826. ----------
  827. G : NetworkX graph or list of nodes
  828. A position will be assigned to every node in G.
  829. subset_key : string (default='subset')
  830. Key of node data to be used as layer subset.
  831. align : string (default='vertical')
  832. The alignment of nodes. Vertical or horizontal.
  833. scale : number (default: 1)
  834. Scale factor for positions.
  835. center : array-like or None
  836. Coordinate pair around which to center the layout.
  837. Returns
  838. -------
  839. pos : dict
  840. A dictionary of positions keyed by node.
  841. Examples
  842. --------
  843. >>> G = nx.complete_multipartite_graph(28, 16, 10)
  844. >>> pos = nx.multipartite_layout(G)
  845. Notes
  846. -----
  847. This algorithm currently only works in two dimensions and does not
  848. try to minimize edge crossings.
  849. Network does not need to be a complete multipartite graph. As long as nodes
  850. have subset_key data, they will be placed in the corresponding layers.
  851. """
  852. import numpy as np
  853. if align not in ("vertical", "horizontal"):
  854. msg = "align must be either vertical or horizontal."
  855. raise ValueError(msg)
  856. G, center = _process_params(G, center=center, dim=2)
  857. if len(G) == 0:
  858. return {}
  859. layers = {}
  860. for v, data in G.nodes(data=True):
  861. try:
  862. layer = data[subset_key]
  863. except KeyError:
  864. msg = "all nodes must have subset_key (default='subset') as data"
  865. raise ValueError(msg)
  866. layers[layer] = [v] + layers.get(layer, [])
  867. # Sort by layer, if possible
  868. try:
  869. layers = sorted(layers.items())
  870. except TypeError:
  871. layers = list(layers.items())
  872. pos = None
  873. nodes = []
  874. width = len(layers)
  875. for i, (_, layer) in enumerate(layers):
  876. height = len(layer)
  877. xs = np.repeat(i, height)
  878. ys = np.arange(0, height, dtype=float)
  879. offset = ((width - 1) / 2, (height - 1) / 2)
  880. layer_pos = np.column_stack([xs, ys]) - offset
  881. if pos is None:
  882. pos = layer_pos
  883. else:
  884. pos = np.concatenate([pos, layer_pos])
  885. nodes.extend(layer)
  886. pos = rescale_layout(pos, scale=scale) + center
  887. if align == "horizontal":
  888. pos = pos[:, ::-1] # swap x and y coords
  889. pos = dict(zip(nodes, pos))
  890. return pos
  891. def arf_layout(
  892. G,
  893. pos=None,
  894. scaling=1,
  895. a=1.1,
  896. etol=1e-6,
  897. dt=1e-3,
  898. max_iter=1000,
  899. ):
  900. """Arf layout for networkx
  901. The attractive and repulsive forces (arf) layout [1]
  902. improves the spring layout in three ways. First, it
  903. prevents congestion of highly connected nodes due to
  904. strong forcing between nodes. Second, it utilizes the
  905. layout space more effectively by preventing large gaps
  906. that spring layout tends to create. Lastly, the arf
  907. layout represents symmmetries in the layout better than
  908. the default spring layout.
  909. Parameters
  910. ----------
  911. G : nx.Graph or nx.DiGraph
  912. Networkx graph.
  913. pos : dict
  914. Initial position of the nodes. If set to None a
  915. random layout will be used.
  916. scaling : float
  917. Scales the radius of the circular layout space.
  918. a : float
  919. Strength of springs between connected nodes. Should be larger than 1. The greater a, the clearer the separation ofunconnected sub clusters.
  920. etol : float
  921. Graduent sum of spring forces must be larger than `etol` before successful termination.
  922. dt : float
  923. Time step for force differential equation simulations.
  924. max_iter : int
  925. Max iterations before termination of the algorithm.
  926. References
  927. .. [1] "Self-Organization Applied to Dynamic Network Layout", M. Geipel,
  928. International Journal of Modern Physics C, 2007, Vol 18, No 10, pp. 1537-1549.
  929. https://doi.org/10.1142/S0129183107011558 https://arxiv.org/abs/0704.1748
  930. Returns
  931. -------
  932. pos : dict
  933. A dictionary of positions keyed by node.
  934. Examples
  935. --------
  936. >>> G = nx.grid_graph((5, 5))
  937. >>> pos = nx.arf_layout(G)
  938. """
  939. import warnings
  940. import numpy as np
  941. if a <= 1:
  942. msg = "The parameter a should be larger than 1"
  943. raise ValueError(msg)
  944. pos_tmp = nx.random_layout(G)
  945. if pos is None:
  946. pos = pos_tmp
  947. else:
  948. for node in G.nodes():
  949. if node not in pos:
  950. pos[node] = pos_tmp[node].copy()
  951. # Initialize spring constant matrix
  952. N = len(G)
  953. # No nodes no computation
  954. if N == 0:
  955. return pos
  956. # init force of springs
  957. K = np.ones((N, N)) - np.eye(N)
  958. node_order = {node: i for i, node in enumerate(G)}
  959. for x, y in G.edges():
  960. if x != y:
  961. idx, jdx = (node_order[i] for i in (x, y))
  962. K[idx, jdx] = a
  963. # vectorize values
  964. p = np.asarray(list(pos.values()))
  965. # equation 10 in [1]
  966. rho = scaling * np.sqrt(N)
  967. # looping variables
  968. error = etol + 1
  969. n_iter = 0
  970. while error > etol:
  971. diff = p[:, np.newaxis] - p[np.newaxis]
  972. A = np.linalg.norm(diff, axis=-1)[..., np.newaxis]
  973. # attraction_force - repulsions force
  974. # suppress nans due to division; caused by diagonal set to zero.
  975. # Does not affect the computation due to nansum
  976. with warnings.catch_warnings():
  977. warnings.simplefilter("ignore")
  978. change = K[..., np.newaxis] * diff - rho / A * diff
  979. change = np.nansum(change, axis=0)
  980. p += change * dt
  981. error = np.linalg.norm(change, axis=-1).sum()
  982. if n_iter > max_iter:
  983. break
  984. n_iter += 1
  985. return dict(zip(G.nodes(), p))
  986. def rescale_layout(pos, scale=1):
  987. """Returns scaled position array to (-scale, scale) in all axes.
  988. The function acts on NumPy arrays which hold position information.
  989. Each position is one row of the array. The dimension of the space
  990. equals the number of columns. Each coordinate in one column.
  991. To rescale, the mean (center) is subtracted from each axis separately.
  992. Then all values are scaled so that the largest magnitude value
  993. from all axes equals `scale` (thus, the aspect ratio is preserved).
  994. The resulting NumPy Array is returned (order of rows unchanged).
  995. Parameters
  996. ----------
  997. pos : numpy array
  998. positions to be scaled. Each row is a position.
  999. scale : number (default: 1)
  1000. The size of the resulting extent in all directions.
  1001. Returns
  1002. -------
  1003. pos : numpy array
  1004. scaled positions. Each row is a position.
  1005. See Also
  1006. --------
  1007. rescale_layout_dict
  1008. """
  1009. # Find max length over all dimensions
  1010. lim = 0 # max coordinate for all axes
  1011. for i in range(pos.shape[1]):
  1012. pos[:, i] -= pos[:, i].mean()
  1013. lim = max(abs(pos[:, i]).max(), lim)
  1014. # rescale to (-scale, scale) in all directions, preserves aspect
  1015. if lim > 0:
  1016. for i in range(pos.shape[1]):
  1017. pos[:, i] *= scale / lim
  1018. return pos
  1019. def rescale_layout_dict(pos, scale=1):
  1020. """Return a dictionary of scaled positions keyed by node
  1021. Parameters
  1022. ----------
  1023. pos : A dictionary of positions keyed by node
  1024. scale : number (default: 1)
  1025. The size of the resulting extent in all directions.
  1026. Returns
  1027. -------
  1028. pos : A dictionary of positions keyed by node
  1029. Examples
  1030. --------
  1031. >>> import numpy as np
  1032. >>> pos = {0: np.array((0, 0)), 1: np.array((1, 1)), 2: np.array((0.5, 0.5))}
  1033. >>> nx.rescale_layout_dict(pos)
  1034. {0: array([-1., -1.]), 1: array([1., 1.]), 2: array([0., 0.])}
  1035. >>> pos = {0: np.array((0, 0)), 1: np.array((-1, 1)), 2: np.array((-0.5, 0.5))}
  1036. >>> nx.rescale_layout_dict(pos, scale=2)
  1037. {0: array([ 2., -2.]), 1: array([-2., 2.]), 2: array([0., 0.])}
  1038. See Also
  1039. --------
  1040. rescale_layout
  1041. """
  1042. import numpy as np
  1043. if not pos: # empty_graph
  1044. return {}
  1045. pos_v = np.array(list(pos.values()))
  1046. pos_v = rescale_layout(pos_v, scale=scale)
  1047. return dict(zip(pos, pos_v))