PedNet 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pednet/graph.py ADDED
@@ -0,0 +1,473 @@
1
+ """The pedigree as a graph.
2
+
3
+ PedNet starts from the view that a pedigree is a directed acyclic graph (DAG): a node for each person, and an edge
4
+ from each parent to each child. This module builds that graph, and the graphs derived from it, with networkx, and
5
+ answers questions about the family's structure from them:
6
+
7
+ - pedigree_graph: the DAG itself, with each person's sex, phenotype and generation on their node
8
+ - mating_graph: the DAG with a node for each couple between the parents and their children, the form in which
9
+ loops in a pedigree are counted
10
+ - moral_graph: the undirected graph in which each child's parents are joined, on which the genotypes are summed out
11
+ for exact likelihoods (see elimination)
12
+ - loops: the pedigree's loops, from consanguinity or from two families married into each other twice
13
+ - kinship and inbreeding: Malécot's coefficients, the probability that genes drawn from two people (or from one
14
+ person's two copies) are identical by descent, for an autosome or the X chromosome
15
+ - relationship: how two people are related, by the paths through their closest common ancestors
16
+ - centrality, founder_influence, pedigree_width, founder_cover: network measures of the people and the pedigree
17
+
18
+ Placeholders (the unknown parents PedNet adds for someone with only one parent in the file) are left out of the
19
+ graphs unless asked for, so that the graphs show the pedigree as it was given.
20
+ """
21
+ from dataclasses import dataclass
22
+ from itertools import combinations
23
+ from typing import Dict, List, Optional, Tuple
24
+
25
+ import networkx as nx
26
+ import numpy as np
27
+
28
+ from .pedigree import FEMALE, MALE, Pedigree
29
+
30
+ _SEX_NAMES = {MALE: 'male', FEMALE: 'female'}
31
+
32
+
33
+ def pedigree_graph(pedigree: Pedigree, placeholders: bool = False) -> nx.DiGraph:
34
+ """
35
+ The pedigree as a DAG: a node for each person and an edge from each parent to each child. Each node has the
36
+ person's sex (1 male, 2 female, 0 unknown), phenotype (2 affected, 1 unaffected, 0 unknown), affected (a bool),
37
+ generation (0 for the oldest), and founder (no parents in the pedigree); each edge has parent, 'father' or
38
+ 'mother'. People PedNet added as unknown parents (placeholders) are left out unless placeholders is True.
39
+ """
40
+ generations = pedigree.generations()
41
+ graph = nx.DiGraph(family=pedigree.family)
42
+ for p in pedigree.people:
43
+ if p.placeholder and not placeholders:
44
+ continue
45
+ graph.add_node(p.id, sex=p.sex, phenotype=p.phenotype, affected=p.affected, generation=generations[p.id],
46
+ founder=p.father is None, added=p.added)
47
+ for p in pedigree.people:
48
+ for role, parent in (('father', p.father), ('mother', p.mother)):
49
+ if parent is not None and parent in graph and p.id in graph:
50
+ graph.add_edge(parent, p.id, parent=role)
51
+ return graph
52
+
53
+
54
+ def mating_graph(pedigree: Pedigree, placeholders: bool = False) -> nx.DiGraph:
55
+ """
56
+ The pedigree with a node for each couple who have children, between them and their children: an edge from each
57
+ partner to the couple's node, and from it to each child. People's nodes have kind 'person' and the attributes of
58
+ pedigree_graph; couples' nodes are ('mating', father, mother), with kind 'mating'. Unlike pedigree_graph, this
59
+ graph has a loop (in its undirected form) exactly where the pedigree does.
60
+ """
61
+ people = pedigree_graph(pedigree, placeholders)
62
+ graph = nx.DiGraph(family=pedigree.family)
63
+ for n, data in people.nodes(data=True):
64
+ graph.add_node(n, kind='person', **data)
65
+ for p in pedigree.people:
66
+ if p.father is None or p.id not in people:
67
+ continue
68
+ parents = [q for q in (p.father, p.mother) if q in people]
69
+ mating = ('mating', p.father, p.mother)
70
+ if mating not in graph:
71
+ graph.add_node(mating, kind='mating')
72
+ for q in parents:
73
+ graph.add_edge(q, mating)
74
+ graph.add_edge(mating, p.id)
75
+ return graph
76
+
77
+
78
+ def moral_graph(pedigree: Pedigree, placeholders: bool = True) -> nx.Graph:
79
+ """
80
+ The moral graph of the pedigree's genotypes: the pedigree's edges without their directions, and an edge between
81
+ the two parents of each child. A child's genotype depends on both parents' together, so this is the graph on
82
+ which the genotypes are summed out for exact likelihoods (see elimination). Placeholders are kept by default,
83
+ since the likelihoods include them.
84
+ """
85
+ graph = pedigree_graph(pedigree, placeholders).to_undirected()
86
+ for p in pedigree.people:
87
+ if p.father is not None and p.father in graph and p.mother in graph:
88
+ graph.add_edge(p.father, p.mother)
89
+ return graph
90
+
91
+
92
+ @dataclass
93
+ class Loop:
94
+ """
95
+ One of a pedigree's independent loops. people is the people on it in order, and couples the couples on it, as
96
+ (father, mother); an unknown partner PedNet added has an ID such as '1~partner'. kind is 'consanguinity' for a
97
+ loop through a related couple (one with a common ancestor in the pedigree), and 'marriage' for one through no
98
+ related couple: two families married into each other more than once.
99
+ """
100
+ people: List[str]
101
+ couples: List[Tuple[str, str]]
102
+ kind: str
103
+
104
+
105
+ def _node_order(node):
106
+ return (1, node[1], node[2]) if isinstance(node, tuple) else (0, node, '')
107
+
108
+
109
+ def _undirected_mating_graph(pedigree: Pedigree) -> nx.Graph:
110
+ """
111
+ The mating graph without its directions, with its nodes and edges in a fixed order, so that the loops found
112
+ don't depend on the order of the lines in the PED file
113
+ """
114
+ directed = mating_graph(pedigree)
115
+ graph = nx.Graph(family=pedigree.family)
116
+ for n in sorted(directed, key=_node_order):
117
+ graph.add_node(n, **directed.nodes[n])
118
+ edges = [tuple(sorted(e, key=_node_order)) for e in directed.edges]
119
+ graph.add_edges_from(sorted(edges, key=lambda e: (_node_order(e[0]), _node_order(e[1]))))
120
+ return graph
121
+
122
+
123
+ def _related(pedigree: Pedigree, a: str, b: str) -> bool:
124
+ """Whether two people share an ancestor in the pedigree (or one is the other's)"""
125
+ return bool((pedigree.ancestors(a) | {a}) & (pedigree.ancestors(b) | {b}))
126
+
127
+
128
+ def loops(pedigree: Pedigree) -> List[Loop]:
129
+ """
130
+ The independent loops of the pedigree: a cycle basis of the mating graph without its directions (by Paton's
131
+ algorithm, as networkx finds it), the loops from consanguinity first. Their number is the cyclomatic number of
132
+ that graph, its edges less its nodes plus the number of its connected parts (see loop_count).
133
+
134
+ Which loops make up a basis is a matter of choice, but how many of them come from consanguinity is not: the
135
+ marriage loops are a basis of the graph without the related couples' nodes, and the rest of the basis, which
136
+ must pass through a related couple, are the loops from consanguinity.
137
+ """
138
+ graph = _undirected_mating_graph(pedigree)
139
+ related = {n for n, data in graph.nodes(data=True)
140
+ if data.get('kind') == 'mating' and _related(pedigree, n[1], n[2])}
141
+ # each cycle as a set of edges, a vector over GF(2), so as to keep only cycles independent of those kept already
142
+ edge_index: Dict[frozenset, int] = {}
143
+ kept: Dict[int, int] = {}
144
+
145
+ def independent(cycle) -> bool:
146
+ vector = 0
147
+ for k, node in enumerate(cycle):
148
+ edge = frozenset((node, cycle[(k + 1) % len(cycle)]))
149
+ vector ^= 1 << edge_index.setdefault(edge, len(edge_index))
150
+ while vector:
151
+ top = vector.bit_length() - 1
152
+ if top not in kept:
153
+ kept[top] = vector
154
+ return True
155
+ vector ^= kept[top]
156
+ return False
157
+
158
+ found = []
159
+ unrelated = graph.subgraph([n for n in graph if n not in related])
160
+ for kind, cycles in (('marriage', nx.cycle_basis(unrelated)), ('consanguinity', nx.cycle_basis(graph))):
161
+ for cycle in cycles:
162
+ if independent(cycle):
163
+ people = [n for n in cycle if graph.nodes[n].get('kind') == 'person']
164
+ couples = [(n[1], n[2]) for n in cycle if graph.nodes[n].get('kind') == 'mating']
165
+ found.append(Loop(people, couples, kind))
166
+ found.sort(key=lambda loop: loop.kind != 'consanguinity')
167
+ return found
168
+
169
+
170
+ def loop_count(pedigree: Pedigree) -> int:
171
+ """The number of independent loops in the pedigree (see loops)"""
172
+ graph = mating_graph(pedigree).to_undirected()
173
+ return graph.number_of_edges() - graph.number_of_nodes() + nx.number_connected_components(graph)
174
+
175
+
176
+ # -- kinship ----------------------------------------------------------------------------------------------------
177
+ def kinship_matrix(pedigree: Pedigree, x_linked: bool = False) -> Tuple[List[str], np.ndarray]:
178
+ """
179
+ Malécot's kinship coefficients of every pair of people: the probability that a gene drawn at random from one
180
+ and a gene drawn from the other, at the same place, are identical by descent. On the diagonal is each person's
181
+ own coefficient, (1 + F) / 2 for inbreeding coefficient F (on the X, 1 for a man, who has one copy). Founders
182
+ are taken as unrelated and not inbred.
183
+
184
+ The coefficients are worked out in the order of the DAG, parents before children, by the recursion that sums
185
+ Wright's path coefficients over every path through a common ancestor: a person's kinship with anyone not their
186
+ descendant is the average of their parents' kinships with that person.
187
+
188
+ For the X chromosome (x_linked), a man has one copy, his mother's: his own coefficient is 1, and his kinship with
189
+ anyone is his mother's. A woman has her father's copy and one of her mother's. Someone of unknown sex (who has no
190
+ children) is taken as equally likely to be either.
191
+
192
+ Returns the people's IDs, in the order of the matrix's rows, and the matrix.
193
+ """
194
+ order = pedigree.order()
195
+ ids = pedigree.ids
196
+ position = {pid: k for k, pid in enumerate(ids)}
197
+ n = len(ids)
198
+ phi = np.zeros((n, n))
199
+ done = np.zeros(n, dtype=bool) # the people worked out so far, none of them descended from the next
200
+ for pid in order:
201
+ p = pedigree[pid]
202
+ i = position[pid]
203
+ if p.father is None:
204
+ self_kinship = 0.5
205
+ row = np.zeros(n)
206
+ if x_linked and p.sex == MALE:
207
+ self_kinship = 1.0
208
+ elif x_linked and p.sex not in (MALE, FEMALE):
209
+ self_kinship = 0.75
210
+ else:
211
+ f, m = position[p.father], position[p.mother]
212
+ if not x_linked:
213
+ row = 0.5 * (phi[f] + phi[m])
214
+ self_kinship = 0.5 * (1 + phi[f, m])
215
+ elif p.sex == MALE:
216
+ row = phi[m].copy()
217
+ self_kinship = 1.0
218
+ elif p.sex == FEMALE:
219
+ row = 0.5 * (phi[f] + phi[m])
220
+ self_kinship = 0.5 * (1 + phi[f, m])
221
+ else:
222
+ row = 0.5 * (phi[m] + 0.5 * (phi[f] + phi[m]))
223
+ self_kinship = 0.5 * (1.0 + 0.5 * (1 + phi[f, m]))
224
+ phi[i, done] = row[done]
225
+ phi[done, i] = row[done]
226
+ phi[i, i] = self_kinship
227
+ done[i] = True
228
+ return ids, phi
229
+
230
+
231
+ def kinship(pedigree: Pedigree, a: str, b: str, x_linked: bool = False) -> float:
232
+ """The kinship coefficient of two people (see kinship_matrix)"""
233
+ ids, phi = kinship_matrix(pedigree, x_linked)
234
+ return float(phi[ids.index(a), ids.index(b)])
235
+
236
+
237
+ def inbreeding(pedigree: Pedigree, x_linked: bool = False) -> Dict[str, float]:
238
+ """
239
+ Each person's inbreeding coefficient: the kinship coefficient of their parents, the probability that their two
240
+ copies are identical by descent (0 for a founder, and on the X for a man, who has one copy). On the X, someone
241
+ of unknown sex has half their parents' kinship, being a woman, with two copies, with probability one half.
242
+ """
243
+ ids, phi = kinship_matrix(pedigree, x_linked)
244
+ position = {pid: k for k, pid in enumerate(ids)}
245
+ out = {}
246
+ for p in pedigree.people:
247
+ if p.father is None or (x_linked and p.sex == MALE):
248
+ out[p.id] = 0.0
249
+ else:
250
+ value = float(phi[position[p.father], position[p.mother]])
251
+ out[p.id] = 0.5 * value if x_linked and p.sex not in (MALE, FEMALE) else value
252
+ return out
253
+
254
+
255
+ # -- relationships ----------------------------------------------------------------------------------------------
256
+ @dataclass
257
+ class Relationship:
258
+ """
259
+ How two people are related. name describes the second person relative to the first ('father', 'half-sister',
260
+ 'first cousin once removed' and so on). up and down are the steps from the first person up to their closest
261
+ common ancestor and from there down to the second, and via the closest common ancestors. degree is the degree
262
+ of that relationship: 1 for parents, children and full siblings, 2 for grandparents, half-siblings, aunts and
263
+ uncles, 3 for first cousins, and so on (the steps of the path, less one for a full relationship). kinship is the
264
+ kinship coefficient and coefficient (2 x kinship) the coefficient of relationship, over every path between the
265
+ two, so they show a closer relationship than the name when there is more than one: double first cousins, whose
266
+ coefficient is a half-sibling's, or relatives whose parents were related.
267
+ """
268
+ name: str
269
+ degree: Optional[int]
270
+ kinship: float
271
+ coefficient: float
272
+ up: Optional[int]
273
+ down: Optional[int]
274
+ via: List[str]
275
+
276
+
277
+ def _steps_up(pedigree: Pedigree, person: str) -> Dict[str, int]:
278
+ """Each ancestor of a person (and the person), with the fewest generations up to them"""
279
+ found = {person: 0}
280
+ frontier = [person]
281
+ while frontier:
282
+ following = []
283
+ for n in frontier:
284
+ for parent in pedigree.parents(n):
285
+ if parent is not None and parent not in found:
286
+ found[parent] = found[n] + 1
287
+ following.append(parent)
288
+ frontier = following
289
+ return found
290
+
291
+
292
+ def _ordinal(n: int) -> str:
293
+ words = {1: 'first', 2: 'second', 3: 'third', 4: 'fourth', 5: 'fifth', 6: 'sixth', 7: 'seventh', 8: 'eighth'}
294
+ return words.get(n, f'{n}th')
295
+
296
+
297
+ def _removed(n: int) -> str:
298
+ return {0: '', 1: ' once removed', 2: ' twice removed', 3: ' three times removed'}.get(n, f' {n} times removed')
299
+
300
+
301
+ def _greats(n: int) -> str:
302
+ return 'great-' * n
303
+
304
+
305
+ def _name(up: int, down: int, sex: int, full: bool) -> str:
306
+ """The name of the second person, up generations to the common ancestor from the first and down from it to them"""
307
+ male, female = sex == MALE, sex == FEMALE
308
+
309
+ def pick(m, f, either):
310
+ return m if male else (f if female else either)
311
+
312
+ if full == 'double' and up >= 2 and down >= 2:
313
+ return 'double ' + _ordinal(min(up, down) - 1) + ' cousin' + _removed(abs(up - down))
314
+
315
+ if up == 0 and down == 0:
316
+ return 'self'
317
+ if down == 0:
318
+ base = pick('father', 'mother', 'parent')
319
+ return base if up == 1 else _greats(up - 2) + 'grand' + base
320
+ if up == 0:
321
+ base = pick('son', 'daughter', 'child')
322
+ return base if down == 1 else _greats(down - 2) + 'grand' + base
323
+ half = '' if full else 'half-'
324
+ if up == 1 and down == 1:
325
+ return half + pick('brother', 'sister', 'sibling')
326
+ if down == 1:
327
+ return half + _greats(up - 2) + pick('uncle', 'aunt', 'aunt or uncle')
328
+ if up == 1:
329
+ return half + _greats(down - 2) + pick('nephew', 'niece', 'niece or nephew')
330
+ return half + _ordinal(min(up, down) - 1) + ' cousin' + _removed(abs(up - down))
331
+
332
+
333
+ def relationship(pedigree: Pedigree, a: str, b: str,
334
+ matrix: Optional[Tuple[List[str], np.ndarray]] = None) -> Relationship:
335
+ """
336
+ How b is related to a, by the shortest path up from a to a common ancestor and down to b. The relationship is
337
+ full (sister rather than half-sister) when the closest common ancestors include a couple, both of whom are at
338
+ that distance, and double (double first cousin) when they include two couples. Partners who aren't related by
339
+ descent are 'unrelated'. via can include the unknown partners PedNet adds, with IDs such as '1~partner'.
340
+ matrix is the pedigree's kinship_matrix, if it has been worked out already, which saves working it out again
341
+ for each pair of a large family.
342
+ """
343
+ ids, phi = kinship_matrix(pedigree) if matrix is None else matrix
344
+ position = {pid: k for k, pid in enumerate(ids)}
345
+ value = float(phi[position[a], position[b]])
346
+ above_a, above_b = _steps_up(pedigree, a), _steps_up(pedigree, b)
347
+ common = set(above_a) & set(above_b)
348
+ if a == b:
349
+ return Relationship('self', 0, value, 2 * value, 0, 0, [a])
350
+ if not common:
351
+ return Relationship('unrelated', None, value, 2 * value, None, None, [])
352
+ distance = {c: above_a[c] + above_b[c] for c in common}
353
+ shortest = min(distance.values())
354
+ closest = sorted(c for c in common if distance[c] == shortest)
355
+ # the steps for the closest ancestor, preferring the most even split (cousins rather than aunts, for a tie)
356
+ best = min(closest, key=lambda c: (abs(above_a[c] - above_b[c]), c))
357
+ up, down = above_a[best], above_b[best]
358
+ # full (siblings, cousins) when two of the closest common ancestors are a couple with a child together, and
359
+ # double when two such couples are (as for the children of two brothers who married two sisters)
360
+ couples = sum(1 for c, d in combinations(closest, 2) if set(pedigree.children(c)) & set(pedigree.children(d)))
361
+ lineal = up == 0 or down == 0
362
+ full = not lineal and couples >= 1
363
+ degree = up + down - (1 if full else 0)
364
+ name = _name(up, down, pedigree[b].sex, 'double' if not lineal and couples >= 2 else full)
365
+ return Relationship(name, degree, value, 2 * value, up, down, closest)
366
+
367
+
368
+ # -- network measures ---------------------------------------------------------------------------------------------
369
+ def centrality(pedigree: Pedigree, measure: str = 'betweenness', directed: bool = False) -> Dict[str, float]:
370
+ """
371
+ Each person's centrality in the pedigree graph, 'degree', 'betweenness' or 'closeness', as networkx works them
372
+ out (normalised), on the graph without its directions unless directed is True
373
+ """
374
+ graph = pedigree_graph(pedigree)
375
+ if not directed:
376
+ graph = graph.to_undirected()
377
+ if measure == 'degree':
378
+ return nx.degree_centrality(graph)
379
+ if measure == 'betweenness':
380
+ return nx.betweenness_centrality(graph)
381
+ if measure == 'closeness':
382
+ return nx.closeness_centrality(graph)
383
+ raise ValueError(f"measure must be 'degree', 'betweenness' or 'closeness', not {measure!r}")
384
+
385
+
386
+ def founder_influence(pedigree: Pedigree) -> Dict[str, float]:
387
+ """
388
+ Nick Taylor's founder influence: for each founder with descendants, the share of the paths down the DAG from the
389
+ founder (counting the path of no steps, to the founder) that end at an affected person
390
+ """
391
+ graph = pedigree_graph(pedigree)
392
+ affected = {n for n, d in graph.nodes(data=True) if d['affected']}
393
+ paths_all: Dict[str, int] = {}
394
+ paths_affected: Dict[str, int] = {}
395
+ for n in reversed(list(nx.topological_sort(graph))):
396
+ paths_all[n] = 1 + sum(paths_all[c] for c in graph.successors(n))
397
+ paths_affected[n] = (n in affected) + sum(paths_affected[c] for c in graph.successors(n))
398
+ return {n: paths_affected[n] / paths_all[n] for n in graph if graph.in_degree(n) == 0 and graph.out_degree(n)}
399
+
400
+
401
+ def pedigree_width(pedigree: Pedigree) -> int:
402
+ """
403
+ The width of the pedigree as a partial order: the most people no one of whom is another's ancestor. By
404
+ Dilworth's theorem it is the number of people less a maximum matching of the transitive closure, which is how it
405
+ is found.
406
+ """
407
+ graph = pedigree_graph(pedigree)
408
+ closure = nx.transitive_closure_dag(graph)
409
+ bipartite = nx.Graph()
410
+ left = [('L', n) for n in graph]
411
+ bipartite.add_nodes_from(left)
412
+ bipartite.add_nodes_from(('R', n) for n in graph)
413
+ bipartite.add_edges_from((('L', u), ('R', v)) for u, v in closure.edges)
414
+ matching = nx.bipartite.hopcroft_karp_matching(bipartite, top_nodes=left)
415
+ return graph.number_of_nodes() - len(matching) // 2
416
+
417
+
418
+ def founder_cover(pedigree: Pedigree) -> List[str]:
419
+ """
420
+ A small set of founders between whom everyone in the pedigree descends (or is one), chosen greedily, each time
421
+ the founder who covers most of the people not yet covered: Nick Taylor's minimal founder cover
422
+ """
423
+ graph = pedigree_graph(pedigree)
424
+ founders = sorted(n for n in graph if graph.in_degree(n) == 0)
425
+ reach = {f: nx.descendants(graph, f) | {f} for f in founders}
426
+ uncovered, cover = set(graph), []
427
+ while uncovered:
428
+ best = max(founders, key=lambda f: (len(reach[f] & uncovered), -founders.index(f)))
429
+ if not reach[best] & uncovered:
430
+ break
431
+ cover.append(best)
432
+ uncovered -= reach[best]
433
+ return cover
434
+
435
+
436
+ @dataclass
437
+ class Elimination:
438
+ """
439
+ The order in which the genotypes are summed out for exact likelihoods, from the moral graph. width is the most
440
+ people in one step's table, so width - 1 bounds the treewidth of the moral graph from above, and the cost of a
441
+ likelihood grows as the number of genotype states to the power of width. exact is whether the largest table is
442
+ small enough for PedNet to work out exact likelihoods at all.
443
+ """
444
+ order: List[str]
445
+ width: int
446
+ largest_table: int
447
+ exact: bool = True
448
+
449
+
450
+ def elimination(pedigree: Pedigree, x_linked: bool = False) -> Elimination:
451
+ """The elimination order PedNet's likelihood engine uses for the pedigree (see engine.Plan), however large"""
452
+ from .engine import Network
453
+ net = Network(pedigree, x_linked, check=False)
454
+ order = [net.ids[var] for _, var, _, _ in net.plan.steps]
455
+ return Elimination(order, net.plan.width, net.plan.largest, net.plan.feasible)
456
+
457
+
458
+ def write_graph(pedigree: Pedigree, path, mating: bool = False):
459
+ """
460
+ Writes the pedigree graph (or the mating graph) as GraphML, for network tools such as Gephi or Cytoscape. The
461
+ couples' nodes of the mating graph are named 'mating:father:mother', where an unknown partner PedNet added has
462
+ an ID such as '1~partner'.
463
+ """
464
+ graph = mating_graph(pedigree) if mating else pedigree_graph(pedigree)
465
+ if mating:
466
+ graph = nx.relabel_nodes(graph, {n: f'mating:{n[1]}:{n[2]}' for n in graph if isinstance(n, tuple)})
467
+ for _, data in graph.nodes(data=True):
468
+ for key, value in list(data.items()):
469
+ if isinstance(value, (bool, np.bool_)):
470
+ data[key] = bool(value)
471
+ elif value is None:
472
+ data[key] = ''
473
+ nx.write_graphml(graph, path)