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/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