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/pedigree.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Pedigrees: reading PED files, checking them, and the relationships that PedNet needs.
|
|
2
|
+
|
|
3
|
+
A PED file has one person per line, in six columns: family, person, father, mother, sex
|
|
4
|
+
(1 male, 2 female, 0 unknown) and phenotype (2 affected, 1 unaffected, 0 or -9 unknown).
|
|
5
|
+
A father or mother of 0 means that parent isn't in the file.
|
|
6
|
+
"""
|
|
7
|
+
import io
|
|
8
|
+
import numbers
|
|
9
|
+
import re
|
|
10
|
+
from collections import deque
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
MALE, FEMALE, UNKNOWN_SEX = 1, 2, 0
|
|
15
|
+
AFFECTED, UNAFFECTED, UNKNOWN = 2, 1, 0
|
|
16
|
+
|
|
17
|
+
_NO_PARENT = {'', '0', '-9', 'na', 'nan', 'none', '.', '0.0'}
|
|
18
|
+
_SEXES = {'1': MALE, '1.0': MALE, 'm': MALE, 'male': MALE, '2': FEMALE, '2.0': FEMALE, 'f': FEMALE,
|
|
19
|
+
'female': FEMALE}
|
|
20
|
+
_PHENOTYPES = {'2': AFFECTED, '1': UNAFFECTED, '2.0': AFFECTED, '1.0': UNAFFECTED,
|
|
21
|
+
'affected': AFFECTED, 'unaffected': UNAFFECTED}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _id(value) -> str:
|
|
25
|
+
"""A person's ID as text. A whole number stored as a decimal, as pandas stores the IDs in a column with a blank
|
|
26
|
+
in it (1.0), is written as the whole number (1), so that it matches the same ID in a column of whole numbers."""
|
|
27
|
+
if isinstance(value, numbers.Real) and not isinstance(value, numbers.Integral):
|
|
28
|
+
number = float(value)
|
|
29
|
+
if number == number and number.is_integer():
|
|
30
|
+
return str(int(number))
|
|
31
|
+
return str(value).strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parent(value) -> Optional[str]:
|
|
35
|
+
"""A parent's ID, or None when the parent isn't in the pedigree (0, a blank, or a missing value)"""
|
|
36
|
+
if value is None:
|
|
37
|
+
return None
|
|
38
|
+
text = _id(value)
|
|
39
|
+
if text.lower() in _NO_PARENT:
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
if float(text) == 0:
|
|
43
|
+
return None
|
|
44
|
+
except ValueError:
|
|
45
|
+
pass
|
|
46
|
+
return text
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sex(value) -> int:
|
|
50
|
+
return _SEXES.get(str(value).strip().lower(), UNKNOWN_SEX)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _phenotype(value) -> int:
|
|
54
|
+
return _PHENOTYPES.get(str(value).strip().lower(), UNKNOWN)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Person:
|
|
59
|
+
"""One person in a pedigree. father and mother are IDs, or None for a parent who isn't in the pedigree."""
|
|
60
|
+
id: str
|
|
61
|
+
father: Optional[str] = None
|
|
62
|
+
mother: Optional[str] = None
|
|
63
|
+
sex: int = UNKNOWN_SEX
|
|
64
|
+
phenotype: int = UNKNOWN
|
|
65
|
+
added: bool = False # put in by PedNet: a parent with no line in the file, or the other parent of someone with only one
|
|
66
|
+
placeholder: bool = False # added with an ID of PedNet's own (the other parent of someone with only one)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def affected(self) -> bool:
|
|
70
|
+
return self.phenotype == AFFECTED
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def unaffected(self) -> bool:
|
|
74
|
+
return self.phenotype == UNAFFECTED
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PedigreeError(ValueError):
|
|
78
|
+
"""A pedigree that can't be used: someone their own ancestor, a person listed twice, or a parent of both sexes"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Pedigree:
|
|
82
|
+
"""
|
|
83
|
+
One family. People are kept in the order given. Each person has either both parents in the pedigree or neither:
|
|
84
|
+
when only one parent is given, PedNet adds the other as an unknown person, and a parent with no line of their own
|
|
85
|
+
is added too (with the sex their role gives them), so that the likelihoods can treat everyone alike.
|
|
86
|
+
|
|
87
|
+
notes lists anything PedNet changed or found odd while reading the family.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, people: Iterable[Person], family: str = ''):
|
|
91
|
+
self.family = family
|
|
92
|
+
self.notes: List[str] = []
|
|
93
|
+
self.people: List[Person] = []
|
|
94
|
+
self.index: Dict[str, int] = {}
|
|
95
|
+
for person in people:
|
|
96
|
+
if person.id in self.index:
|
|
97
|
+
raise PedigreeError(f'Family {family}: {person.id} is listed more than once')
|
|
98
|
+
self.index[person.id] = len(self.people)
|
|
99
|
+
self.people.append(person)
|
|
100
|
+
self._complete()
|
|
101
|
+
self._children: Dict[str, List[str]] = {p.id: [] for p in self.people}
|
|
102
|
+
for p in self.people:
|
|
103
|
+
for parent in (p.father, p.mother):
|
|
104
|
+
if parent is not None:
|
|
105
|
+
self._children[parent].append(p.id)
|
|
106
|
+
self._order = self._topological_order()
|
|
107
|
+
|
|
108
|
+
# -- building ---------------------------------------------------------------------------------------------
|
|
109
|
+
def _add(self, person: Person):
|
|
110
|
+
self.index[person.id] = len(self.people)
|
|
111
|
+
self.people.append(person)
|
|
112
|
+
|
|
113
|
+
def _complete(self):
|
|
114
|
+
"""Add missing parents, give each parent the sex of their role, and check that no one is both a father and a mother"""
|
|
115
|
+
# a parent written as a decimal (1.0), as a file saved from a table with blanks in its parent columns can
|
|
116
|
+
# have them, is the person with the whole-number ID (1), if no one has the decimal ID itself
|
|
117
|
+
renamed = set()
|
|
118
|
+
for p in self.people:
|
|
119
|
+
for role in ('father', 'mother'):
|
|
120
|
+
parent = getattr(p, role)
|
|
121
|
+
if parent is None or parent in self.index:
|
|
122
|
+
continue
|
|
123
|
+
whole = parent.split('.')[0] if re.fullmatch(r'-?\d+\.0+', parent) else None
|
|
124
|
+
if whole is not None and whole in self.index:
|
|
125
|
+
setattr(p, role, whole)
|
|
126
|
+
renamed.add((parent, whole))
|
|
127
|
+
for parent, whole in sorted(renamed):
|
|
128
|
+
self.notes.append(f'the parent {parent} is taken to be {whole}, the same number written without decimals')
|
|
129
|
+
fathers = {p.father for p in self.people if p.father is not None}
|
|
130
|
+
mothers = {p.mother for p in self.people if p.mother is not None}
|
|
131
|
+
both = fathers & mothers
|
|
132
|
+
if both:
|
|
133
|
+
raise PedigreeError(f'Family {self.family}: {", ".join(sorted(both))} listed as both a father and a mother')
|
|
134
|
+
# the children of each parent whose other parent isn't in the file, who share one unknown other parent
|
|
135
|
+
partnerless: Dict[Tuple[str, str], List[Person]] = {}
|
|
136
|
+
for p in list(self.people):
|
|
137
|
+
for role, sex in (('father', MALE), ('mother', FEMALE)):
|
|
138
|
+
parent = getattr(p, role)
|
|
139
|
+
if parent is not None and parent not in self.index:
|
|
140
|
+
self._add(Person(parent, sex=sex, added=True))
|
|
141
|
+
self.notes.append(f'{parent}, the {role} of {p.id}, has no line of their own, so was added with an unknown phenotype')
|
|
142
|
+
if (p.father is None) != (p.mother is None):
|
|
143
|
+
missing = 'father' if p.father is None else 'mother'
|
|
144
|
+
partnerless.setdefault((p.mother if missing == 'father' else p.father, missing), []).append(p)
|
|
145
|
+
for (known, missing), children in partnerless.items():
|
|
146
|
+
new_id = f'{known}~partner'
|
|
147
|
+
while new_id in self.index:
|
|
148
|
+
new_id += '~'
|
|
149
|
+
self._add(Person(new_id, sex=MALE if missing == 'father' else FEMALE, added=True, placeholder=True))
|
|
150
|
+
for child in children:
|
|
151
|
+
setattr(child, missing, new_id)
|
|
152
|
+
names = [c.id for c in children]
|
|
153
|
+
who = names[0] if len(names) == 1 else ', '.join(names[:-1]) + ' and ' + names[-1]
|
|
154
|
+
known_role = 'mother' if missing == 'father' else 'father'
|
|
155
|
+
self.notes.append(f'{who} {"has" if len(names) == 1 else "have"} only a {known_role} ({known}) in the file, '
|
|
156
|
+
f'so an unknown {missing} was added' +
|
|
157
|
+
('' if len(names) == 1 else ', the same one for all of them'))
|
|
158
|
+
for p in self.people:
|
|
159
|
+
if p.id in fathers and p.sex != MALE:
|
|
160
|
+
if p.sex == FEMALE:
|
|
161
|
+
self.notes.append(f'{p.id} is a father but is listed as female, and is taken as male')
|
|
162
|
+
p.sex = MALE
|
|
163
|
+
if p.id in mothers and p.sex != FEMALE:
|
|
164
|
+
if p.sex == MALE:
|
|
165
|
+
self.notes.append(f'{p.id} is a mother but is listed as male, and is taken as female')
|
|
166
|
+
p.sex = FEMALE
|
|
167
|
+
|
|
168
|
+
def _topological_order(self) -> List[str]:
|
|
169
|
+
"""Everyone with their parents before them; a PedigreeError if someone is their own ancestor"""
|
|
170
|
+
waiting = {p.id: sum(parent is not None for parent in (p.father, p.mother)) for p in self.people}
|
|
171
|
+
queue = deque(p.id for p in self.people if waiting[p.id] == 0)
|
|
172
|
+
order = []
|
|
173
|
+
while queue:
|
|
174
|
+
n = queue.popleft()
|
|
175
|
+
order.append(n)
|
|
176
|
+
for child in self._children[n]:
|
|
177
|
+
waiting[child] -= 1
|
|
178
|
+
if waiting[child] == 0:
|
|
179
|
+
queue.append(child)
|
|
180
|
+
if len(order) != len(self.people):
|
|
181
|
+
stuck = sorted(n for n, w in waiting.items() if w > 0)
|
|
182
|
+
raise PedigreeError(f'Family {self.family}: some people are their own ancestors ({", ".join(stuck[:5])})')
|
|
183
|
+
return order
|
|
184
|
+
|
|
185
|
+
# -- relationships -----------------------------------------------------------------------------------------
|
|
186
|
+
def __len__(self):
|
|
187
|
+
return len(self.people)
|
|
188
|
+
|
|
189
|
+
def __iter__(self):
|
|
190
|
+
return iter(self.people)
|
|
191
|
+
|
|
192
|
+
def __getitem__(self, person_id: str) -> Person:
|
|
193
|
+
return self.people[self.index[person_id]]
|
|
194
|
+
|
|
195
|
+
def __contains__(self, person_id) -> bool:
|
|
196
|
+
return person_id in self.index
|
|
197
|
+
|
|
198
|
+
def __repr__(self):
|
|
199
|
+
return f'Pedigree({self.family!r}, {len(self.people)} people, {len(self.affected())} affected)'
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def ids(self) -> List[str]:
|
|
203
|
+
return [p.id for p in self.people]
|
|
204
|
+
|
|
205
|
+
def children(self, person_id: str) -> List[str]:
|
|
206
|
+
return list(self._children[person_id])
|
|
207
|
+
|
|
208
|
+
def parents(self, person_id: str) -> Tuple[Optional[str], Optional[str]]:
|
|
209
|
+
p = self[person_id]
|
|
210
|
+
return p.father, p.mother
|
|
211
|
+
|
|
212
|
+
def founders(self) -> List[str]:
|
|
213
|
+
return [p.id for p in self.people if p.father is None]
|
|
214
|
+
|
|
215
|
+
def affected(self) -> List[str]:
|
|
216
|
+
return [p.id for p in self.people if p.affected]
|
|
217
|
+
|
|
218
|
+
def order(self) -> List[str]:
|
|
219
|
+
"""Everyone, each after their parents"""
|
|
220
|
+
return list(self._order)
|
|
221
|
+
|
|
222
|
+
def ancestors(self, person_id: str) -> set:
|
|
223
|
+
found, stack = set(), [person_id]
|
|
224
|
+
while stack:
|
|
225
|
+
for parent in self.parents(stack.pop()):
|
|
226
|
+
if parent is not None and parent not in found:
|
|
227
|
+
found.add(parent)
|
|
228
|
+
stack.append(parent)
|
|
229
|
+
return found
|
|
230
|
+
|
|
231
|
+
def generations(self) -> Dict[str, int]:
|
|
232
|
+
"""
|
|
233
|
+
Each person's generation, 0 for the oldest. A child is one generation below its parents, so partners share a
|
|
234
|
+
generation and people who married in are in their partner's. Where that can't hold throughout (people of
|
|
235
|
+
different generations with a child together), each child is one below its lower parent, and each person with no
|
|
236
|
+
parents one above their highest child. The result doesn't depend on the order of the people.
|
|
237
|
+
"""
|
|
238
|
+
level: Dict[str, int] = {}
|
|
239
|
+
seen = set()
|
|
240
|
+
for start in self._order:
|
|
241
|
+
if start in seen:
|
|
242
|
+
continue
|
|
243
|
+
comp = {start: 0}
|
|
244
|
+
queue = deque([start])
|
|
245
|
+
consistent = True
|
|
246
|
+
while queue:
|
|
247
|
+
n = queue.popleft()
|
|
248
|
+
p = self[n]
|
|
249
|
+
steps = [(c, 1) for c in self._children[n]] + [(q, -1) for q in (p.father, p.mother) if q is not None]
|
|
250
|
+
for other, step in steps:
|
|
251
|
+
if other not in comp:
|
|
252
|
+
comp[other] = comp[n] + step
|
|
253
|
+
queue.append(other)
|
|
254
|
+
elif comp[other] != comp[n] + step:
|
|
255
|
+
consistent = False
|
|
256
|
+
seen.update(comp)
|
|
257
|
+
if not consistent:
|
|
258
|
+
members = [n for n in self._order if n in comp]
|
|
259
|
+
comp = {}
|
|
260
|
+
for n in members:
|
|
261
|
+
p = self[n]
|
|
262
|
+
comp[n] = max(comp[p.father], comp[p.mother]) + 1 if p.father is not None else 0
|
|
263
|
+
for n in members:
|
|
264
|
+
if self[n].father is None and self._children[n]:
|
|
265
|
+
comp[n] = min(comp[c] for c in self._children[n]) - 1
|
|
266
|
+
oldest = min(comp.values())
|
|
267
|
+
for n, l in comp.items():
|
|
268
|
+
level[n] = l - oldest
|
|
269
|
+
return level
|
|
270
|
+
|
|
271
|
+
def consanguineous(self) -> Dict[str, str]:
|
|
272
|
+
"""The children of related parents, each with the parents' closest common ancestor"""
|
|
273
|
+
found = {}
|
|
274
|
+
for p in self.people:
|
|
275
|
+
if p.father is None:
|
|
276
|
+
continue
|
|
277
|
+
father_side = self.ancestors(p.father) | {p.father}
|
|
278
|
+
mother_side = self.ancestors(p.mother) | {p.mother}
|
|
279
|
+
common = father_side & mother_side
|
|
280
|
+
if common:
|
|
281
|
+
# the closest: the common ancestor with the fewest steps down to the two parents
|
|
282
|
+
def steps(a):
|
|
283
|
+
return self._steps_down(a, p.father) + self._steps_down(a, p.mother)
|
|
284
|
+
found[p.id] = min(sorted(common), key=steps)
|
|
285
|
+
return found
|
|
286
|
+
|
|
287
|
+
def _steps_down(self, ancestor: str, person: str) -> int:
|
|
288
|
+
"""The fewest parent-to-child steps from ancestor down to person"""
|
|
289
|
+
distance = {ancestor: 0}
|
|
290
|
+
queue = deque([ancestor])
|
|
291
|
+
while queue:
|
|
292
|
+
n = queue.popleft()
|
|
293
|
+
if n == person:
|
|
294
|
+
return distance[n]
|
|
295
|
+
for c in self._children[n]:
|
|
296
|
+
if c not in distance:
|
|
297
|
+
distance[c] = distance[n] + 1
|
|
298
|
+
queue.append(c)
|
|
299
|
+
return 10 ** 6
|
|
300
|
+
|
|
301
|
+
def to_networkx(self, placeholders: bool = False):
|
|
302
|
+
"""The pedigree as a networkx DAG, with an edge from each parent to each child (see graph.pedigree_graph)"""
|
|
303
|
+
from .graph import pedigree_graph
|
|
304
|
+
return pedigree_graph(self, placeholders)
|
|
305
|
+
|
|
306
|
+
def problems(self) -> List[str]:
|
|
307
|
+
"""Things in the family worth checking, besides the notes made while reading it"""
|
|
308
|
+
found = []
|
|
309
|
+
for p in self.people:
|
|
310
|
+
if p.sex == UNKNOWN_SEX and not p.placeholder:
|
|
311
|
+
found.append(f'{p.id} has no sex recorded')
|
|
312
|
+
if not self.affected():
|
|
313
|
+
found.append('no one is affected')
|
|
314
|
+
return found
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def read_ped(source, family: Optional[str] = None) -> Dict[str, Pedigree]:
|
|
318
|
+
"""
|
|
319
|
+
Reads a PED file into a pedigree for each family, keyed by family ID.
|
|
320
|
+
|
|
321
|
+
source is a path, an open file, or the text itself. Columns are separated by tabs or spaces; lines starting
|
|
322
|
+
with # are skipped, as are any columns after the sixth. family, if given, keeps only that family.
|
|
323
|
+
"""
|
|
324
|
+
if hasattr(source, 'read'):
|
|
325
|
+
text = source.read()
|
|
326
|
+
elif isinstance(source, str) and '\n' in source:
|
|
327
|
+
text = source
|
|
328
|
+
else:
|
|
329
|
+
with open(source, encoding='utf-8-sig') as handle:
|
|
330
|
+
text = handle.read()
|
|
331
|
+
rows: Dict[str, List[Person]] = {}
|
|
332
|
+
for number, line in enumerate(io.StringIO(text), start=1):
|
|
333
|
+
line = line.strip()
|
|
334
|
+
if not line or line.startswith('#'):
|
|
335
|
+
continue
|
|
336
|
+
fields = line.split()
|
|
337
|
+
if len(fields) < 6:
|
|
338
|
+
raise PedigreeError(f'Line {number} has {len(fields)} columns, not the six of a PED file: {line}')
|
|
339
|
+
fam, pid, father, mother, sex, phenotype = fields[:6]
|
|
340
|
+
if family is not None and fam != family:
|
|
341
|
+
continue
|
|
342
|
+
rows.setdefault(fam, []).append(Person(pid, _parent(father), _parent(mother), _sex(sex), _phenotype(phenotype)))
|
|
343
|
+
if family is not None and family not in rows:
|
|
344
|
+
raise PedigreeError(f'There is no family {family} in the PED file')
|
|
345
|
+
return {fam: Pedigree(people, fam) for fam, people in rows.items()}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def pedigree_from_rows(rows, family: str = '') -> Pedigree:
|
|
349
|
+
"""
|
|
350
|
+
A pedigree from rows of (person, father, mother, sex, phenotype), or of dicts or pandas rows with the PED column
|
|
351
|
+
names (IndividualID, PaternalID, MaternalID, Sex, Phenotype), as PedGen and PedInterp make them
|
|
352
|
+
"""
|
|
353
|
+
people = []
|
|
354
|
+
if hasattr(rows, 'iterrows'):
|
|
355
|
+
rows = [row for _, row in rows.iterrows()]
|
|
356
|
+
for row in rows:
|
|
357
|
+
if not isinstance(row, (tuple, list)):
|
|
358
|
+
pid, father, mother = row['IndividualID'], row['PaternalID'], row['MaternalID']
|
|
359
|
+
sex, phenotype = row['Sex'], row['Phenotype']
|
|
360
|
+
if not family and 'FamilyID' in row:
|
|
361
|
+
family = _id(row['FamilyID'])
|
|
362
|
+
else:
|
|
363
|
+
pid, father, mother, sex, phenotype = row
|
|
364
|
+
people.append(Person(_id(pid), _parent(father), _parent(mother), _sex(_id(sex)), _phenotype(_id(phenotype))))
|
|
365
|
+
return Pedigree(people, family)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def write_ped(pedigree: Pedigree, path_or_file=None) -> str:
|
|
369
|
+
"""
|
|
370
|
+
The pedigree as PED text, written to path_or_file too if one is given. Parents PedNet added because they had no line
|
|
371
|
+
in the file get a line of their own; the unknown parents it made up for people with only one parent are left out.
|
|
372
|
+
"""
|
|
373
|
+
lines = []
|
|
374
|
+
for p in pedigree.people:
|
|
375
|
+
if p.placeholder:
|
|
376
|
+
continue
|
|
377
|
+
father = p.father if p.father is not None and not pedigree[p.father].placeholder else '0'
|
|
378
|
+
mother = p.mother if p.mother is not None and not pedigree[p.mother].placeholder else '0'
|
|
379
|
+
lines.append('\t'.join([pedigree.family or '0', p.id, father, mother, str(p.sex), str(p.phenotype)]))
|
|
380
|
+
text = '\n'.join(lines) + '\n'
|
|
381
|
+
if path_or_file is not None:
|
|
382
|
+
if hasattr(path_or_file, 'write'):
|
|
383
|
+
path_or_file.write(text)
|
|
384
|
+
else:
|
|
385
|
+
with open(path_or_file, 'w') as handle:
|
|
386
|
+
handle.write(text)
|
|
387
|
+
return text
|