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/__init__.py +24 -0
- pednet/__main__.py +5 -0
- pednet/_trained.py +29 -0
- pednet/cli.py +378 -0
- pednet/compound.py +180 -0
- pednet/engine.py +203 -0
- pednet/features.py +522 -0
- pednet/genotypes.py +253 -0
- pednet/graph.py +473 -0
- pednet/inheritance.py +262 -0
- pednet/models.py +209 -0
- pednet/pedigree.py +387 -0
- pednet/segregation.py +751 -0
- pednet/simulate.py +277 -0
- pednet-2.0.0.dist-info/METADATA +352 -0
- pednet-2.0.0.dist-info/RECORD +20 -0
- pednet-2.0.0.dist-info/WHEEL +5 -0
- pednet-2.0.0.dist-info/entry_points.txt +2 -0
- pednet-2.0.0.dist-info/licenses/LICENSE +21 -0
- pednet-2.0.0.dist-info/top_level.txt +1 -0
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)
|