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/simulate.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Simulated families, for testing and benchmarking PedNet.
|
|
2
|
+
|
|
3
|
+
A family is a pedigree, the genotypes of a disease locus passed down it by Mendel's laws, phenotypes drawn from those
|
|
4
|
+
genotypes, and a set of candidate variants: the causal one (or for a compound heterozygote, two in one gene), and
|
|
5
|
+
rare background variants passed down the same pedigree independently of it, as a family's other rare variants are.
|
|
6
|
+
Some people are sequenced; the proband always is.
|
|
7
|
+
"""
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .models import X_LINKED
|
|
14
|
+
from .pedigree import FEMALE, MALE, Pedigree, Person
|
|
15
|
+
from .segregation import Variant
|
|
16
|
+
|
|
17
|
+
Row = Tuple[str, Optional[str], Optional[str], int]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SimulatedFamily:
|
|
22
|
+
pedigree: Pedigree
|
|
23
|
+
mode: str
|
|
24
|
+
proband: str
|
|
25
|
+
disease: Dict[str, int] # each person's copies of the disease allele (of a compound
|
|
26
|
+
# heterozygote's two, the copies of the gene carrying either)
|
|
27
|
+
variants: List[Variant] # the causal variants first: see causal
|
|
28
|
+
sequenced: List[str] = field(default_factory=list)
|
|
29
|
+
penetrance: float = 0.9
|
|
30
|
+
phenocopy: float = 0.001
|
|
31
|
+
causal: Tuple[str, ...] = ('causal',) # the ids of the causal variant, or of a compound heterozygote's two
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _tree(rng: np.random.Generator, generations: int, children: Tuple[int, int], partner: float) -> List[Row]:
|
|
35
|
+
rows: List[Row] = [('1', None, None, MALE), ('2', None, None, FEMALE)]
|
|
36
|
+
next_id = 3
|
|
37
|
+
couples = [('1', '2')]
|
|
38
|
+
for g in range(1, generations):
|
|
39
|
+
new_couples = []
|
|
40
|
+
for father, mother in couples:
|
|
41
|
+
kids = []
|
|
42
|
+
for _ in range(int(rng.integers(children[0], children[1] + 1))):
|
|
43
|
+
sex = MALE if rng.random() < 0.5 else FEMALE
|
|
44
|
+
rows.append((str(next_id), father, mother, sex))
|
|
45
|
+
kids.append((str(next_id), sex))
|
|
46
|
+
next_id += 1
|
|
47
|
+
if g == generations - 1:
|
|
48
|
+
continue
|
|
49
|
+
for k, (kid, sex) in enumerate(kids):
|
|
50
|
+
# the first child always has a family of their own, so that every generation is there
|
|
51
|
+
if k == 0 or rng.random() < partner:
|
|
52
|
+
spouse = str(next_id)
|
|
53
|
+
next_id += 1
|
|
54
|
+
rows.append((spouse, None, None, FEMALE if sex == MALE else MALE))
|
|
55
|
+
new_couples.append((kid, spouse) if sex == MALE else (spouse, kid))
|
|
56
|
+
couples = new_couples
|
|
57
|
+
return rows
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cousins(rng: np.random.Generator, children: Tuple[int, int]) -> List[Row]:
|
|
61
|
+
"""Four generations: two siblings' children, first cousins, have children of their own"""
|
|
62
|
+
rows: List[Row] = [('1', None, None, MALE), ('2', None, None, FEMALE),
|
|
63
|
+
('3', '1', '2', MALE), ('4', '1', '2', FEMALE), ('5', None, None, FEMALE), ('6', None, None, MALE),
|
|
64
|
+
('7', '3', '5', MALE), ('8', '6', '4', FEMALE)]
|
|
65
|
+
next_id = 9
|
|
66
|
+
|
|
67
|
+
def add_children(father, mother, n):
|
|
68
|
+
nonlocal next_id
|
|
69
|
+
for _ in range(n):
|
|
70
|
+
rows.append((str(next_id), father, mother, MALE if rng.random() < 0.5 else FEMALE))
|
|
71
|
+
next_id += 1
|
|
72
|
+
|
|
73
|
+
add_children('1', '2', int(rng.integers(0, 3)))
|
|
74
|
+
add_children('3', '5', int(rng.integers(0, 3)))
|
|
75
|
+
add_children('6', '4', int(rng.integers(0, 3)))
|
|
76
|
+
# the related couple usually has a larger family, as such families often do
|
|
77
|
+
add_children('7', '8', int(rng.integers(max(children[0], 3), max(children[1], 3) + 3)))
|
|
78
|
+
return rows
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _drop(rows: Sequence[Row], x_linked: bool, founder_copies: Dict[str, int], q: float,
|
|
82
|
+
rng: np.random.Generator) -> Dict[str, int]:
|
|
83
|
+
"""Copies of an allele passed down the pedigree, founders given in founder_copies or drawn from the population"""
|
|
84
|
+
copies: Dict[str, int] = {}
|
|
85
|
+
for pid, father, mother, s in rows:
|
|
86
|
+
if father is None:
|
|
87
|
+
if pid in founder_copies:
|
|
88
|
+
copies[pid] = founder_copies[pid]
|
|
89
|
+
elif x_linked and s == MALE:
|
|
90
|
+
copies[pid] = int(rng.random() < q)
|
|
91
|
+
else:
|
|
92
|
+
copies[pid] = int(rng.random() < q) + int(rng.random() < q)
|
|
93
|
+
continue
|
|
94
|
+
from_mother = int(rng.random() < copies[mother] / 2)
|
|
95
|
+
if x_linked:
|
|
96
|
+
copies[pid] = from_mother if s == MALE else copies[father] + from_mother
|
|
97
|
+
else:
|
|
98
|
+
copies[pid] = int(rng.random() < copies[father] / 2) + from_mother
|
|
99
|
+
return copies
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _drop_haplotypes(rows: Sequence[Row], x_linked: bool, founders: Dict[str, Tuple[int, ...]],
|
|
103
|
+
rng: np.random.Generator) -> Dict[str, Tuple[int, ...]]:
|
|
104
|
+
"""
|
|
105
|
+
Each person's copies of a gene, as haplotype labels (0 for a copy with no variant), passed down the pedigree from
|
|
106
|
+
the founders given in founders; other founders have no variants. A man has one copy on the X.
|
|
107
|
+
"""
|
|
108
|
+
haplotypes: Dict[str, Tuple[int, ...]] = {}
|
|
109
|
+
for pid, father, mother, s in rows:
|
|
110
|
+
if father is None:
|
|
111
|
+
haplotypes[pid] = founders.get(pid, (0,) if x_linked and s == MALE else (0, 0))
|
|
112
|
+
continue
|
|
113
|
+
from_mother = haplotypes[mother][int(rng.integers(2))]
|
|
114
|
+
if x_linked:
|
|
115
|
+
haplotypes[pid] = (from_mother,) if s == MALE else (haplotypes[father][0], from_mother)
|
|
116
|
+
else:
|
|
117
|
+
haplotypes[pid] = (haplotypes[father][int(rng.integers(2))], from_mother)
|
|
118
|
+
return haplotypes
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# haplotype labels for a pair of variants: 1 the first, 2 the second, 3 both (in cis)
|
|
122
|
+
_FIRST, _SECOND = {1, 3}, {2, 3}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _affected(mode: str, copies: int, male: bool, penetrance: float, phenocopy: float, rng) -> bool:
|
|
126
|
+
if mode in ('AD', 'XLD'):
|
|
127
|
+
disease = copies >= 1
|
|
128
|
+
elif mode == 'AR':
|
|
129
|
+
disease = copies == 2
|
|
130
|
+
else:
|
|
131
|
+
disease = copies >= 1 if male else copies == 2
|
|
132
|
+
return bool(rng.random() < (penetrance if disease else phenocopy))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _has_disease_genotype(mode: str, copies: int, male: bool) -> bool:
|
|
136
|
+
if mode in ('AD', 'XLD'):
|
|
137
|
+
return copies >= 1
|
|
138
|
+
if mode == 'AR':
|
|
139
|
+
return copies == 2
|
|
140
|
+
return copies >= 1 if male else copies == 2
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def simulate_family(mode: str, rng: Optional[np.random.Generator] = None, generations: int = 3,
|
|
144
|
+
children: Tuple[int, int] = (1, 4), partner: float = 0.6, consanguineous: bool = False,
|
|
145
|
+
penetrance: float = 0.9, phenocopy: float = 0.001, allele_frequency: float = 1e-3,
|
|
146
|
+
min_affected: int = 2, unknown_phenotype: float = 0.0, background: int = 20,
|
|
147
|
+
background_frequency: Tuple[float, float] = (1e-4, 1e-2), sequenced: float = 0.5,
|
|
148
|
+
error_rate: float = 0.0, compound: bool = False, background_pairs: int = 0,
|
|
149
|
+
max_tries: int = 5000) -> SimulatedFamily:
|
|
150
|
+
"""
|
|
151
|
+
One simulated family with a disease of the given mode, found through an affected proband who has the disease
|
|
152
|
+
genotype (one of the affected people in the youngest generation that has any, where possible).
|
|
153
|
+
|
|
154
|
+
generations, children (the range of a couple's children) and partner (the chance that a later child has a family)
|
|
155
|
+
shape the pedigree; consanguineous makes it the four-generation family of a first-cousin marriage instead.
|
|
156
|
+
penetrance, phenocopy and allele_frequency set the disease; min_affected is the fewest affected people the
|
|
157
|
+
family must have. unknown_phenotype is the share of people (besides the proband) whose phenotype is unknown.
|
|
158
|
+
background is the number of other rare variants the proband carries, with frequencies drawn evenly on a log scale
|
|
159
|
+
from background_frequency, each in a gene of its own. sequenced is the chance that each person besides the
|
|
160
|
+
proband is sequenced, and error_rate the chance that a genotype call is wrong.
|
|
161
|
+
|
|
162
|
+
compound makes an autosomal recessive condition a compound heterozygote's: two variants in one gene, 'causal a'
|
|
163
|
+
and 'causal b', brought into the family by two founders. background_pairs is the number of other pairs of rare
|
|
164
|
+
variants in one gene that the proband carries both of (as the gene's two copies, or both on one copy).
|
|
165
|
+
"""
|
|
166
|
+
rng = np.random.default_rng() if rng is None else rng
|
|
167
|
+
x = X_LINKED[mode]
|
|
168
|
+
if compound and mode != 'AR':
|
|
169
|
+
raise ValueError('compound heterozygotes are simulated for autosomal recessive conditions only')
|
|
170
|
+
haplotypes: Dict[str, Tuple[int, ...]] = {}
|
|
171
|
+
for _ in range(max_tries):
|
|
172
|
+
rows = _cousins(rng, children) if consanguineous else _tree(rng, generations, children, partner)
|
|
173
|
+
sex = {r[0]: r[3] for r in rows}
|
|
174
|
+
founders = [r[0] for r in rows if r[1] is None]
|
|
175
|
+
# the disease allele comes into the family through one of the first couple, and for a recessive
|
|
176
|
+
# condition without consanguinity also through someone who married in
|
|
177
|
+
seed: Dict[str, int] = {}
|
|
178
|
+
if mode == 'XLR':
|
|
179
|
+
seed['2'] = 1
|
|
180
|
+
else:
|
|
181
|
+
seed[str(rng.choice(['1', '2']))] = 1
|
|
182
|
+
if mode == 'AR' and (compound or not consanguineous):
|
|
183
|
+
spouses = [f for f in founders if f not in ('1', '2')]
|
|
184
|
+
if spouses:
|
|
185
|
+
seed[str(rng.choice(spouses))] = 1
|
|
186
|
+
else:
|
|
187
|
+
seed['1'] = seed['2'] = 1
|
|
188
|
+
if compound:
|
|
189
|
+
# the first variant from one founder, the second from the other
|
|
190
|
+
first, second = list(seed) if len(seed) == 2 else ('1', '2')
|
|
191
|
+
haplotypes = _drop_haplotypes(rows, x, {first: (1, 0), second: (2, 0)}, rng)
|
|
192
|
+
disease = {pid: sum(1 for h in hs if h) for pid, hs in haplotypes.items()}
|
|
193
|
+
else:
|
|
194
|
+
disease = _drop(rows, x, seed, allele_frequency, rng)
|
|
195
|
+
phenotype = {pid: 2 if _affected(mode, disease[pid], sex[pid] == MALE, penetrance, phenocopy, rng) else 1
|
|
196
|
+
for pid in disease}
|
|
197
|
+
affected = [pid for pid in disease if phenotype[pid] == 2]
|
|
198
|
+
if len(affected) < min_affected:
|
|
199
|
+
continue
|
|
200
|
+
candidates = [pid for pid in affected if _has_disease_genotype(mode, disease[pid], sex[pid] == MALE)]
|
|
201
|
+
if not candidates:
|
|
202
|
+
continue
|
|
203
|
+
generation_of = Pedigree([Person(r[0], r[1], r[2], r[3], phenotype[r[0]]) for r in rows]).generations()
|
|
204
|
+
youngest = max(generation_of[pid] for pid in candidates)
|
|
205
|
+
proband = str(rng.choice([pid for pid in candidates if generation_of[pid] == youngest]))
|
|
206
|
+
break
|
|
207
|
+
else:
|
|
208
|
+
raise RuntimeError(f'No family with {min_affected} or more affected people in {max_tries} tries')
|
|
209
|
+
|
|
210
|
+
for pid in phenotype:
|
|
211
|
+
if pid != proband and rng.random() < unknown_phenotype:
|
|
212
|
+
phenotype[pid] = 0
|
|
213
|
+
people = [Person(r[0], r[1], r[2], r[3], phenotype[r[0]]) for r in rows]
|
|
214
|
+
pedigree = Pedigree(people, f'SIM-{mode}')
|
|
215
|
+
seq = [pid for pid in disease if pid == proband or rng.random() < sequenced]
|
|
216
|
+
|
|
217
|
+
def call(true_copies, male):
|
|
218
|
+
if error_rate and rng.random() < error_rate:
|
|
219
|
+
options = [c for c in ((0, 1) if (x and male) else (0, 1, 2)) if c != true_copies]
|
|
220
|
+
return int(rng.choice(options))
|
|
221
|
+
return true_copies
|
|
222
|
+
|
|
223
|
+
chrom = 'X' if x else '1'
|
|
224
|
+
|
|
225
|
+
def variant(name, copies, frequency, gene):
|
|
226
|
+
genotypes = {pid: call(copies[pid], sex[pid] == MALE) for pid in seq}
|
|
227
|
+
return Variant(name, genotypes, chrom, None, None, None, frequency, gene)
|
|
228
|
+
|
|
229
|
+
def counts(labels):
|
|
230
|
+
return {pid: sum(1 for h in hs if h in labels) for pid, hs in haplotypes.items()}
|
|
231
|
+
|
|
232
|
+
low, high = np.log10(background_frequency[0]), np.log10(background_frequency[1])
|
|
233
|
+
|
|
234
|
+
def frequency():
|
|
235
|
+
return float(10 ** rng.uniform(low, high))
|
|
236
|
+
|
|
237
|
+
# the causal variants' recorded frequencies are drawn like the background variants', so that they give nothing away
|
|
238
|
+
if compound:
|
|
239
|
+
variants = [variant('causal a', counts(_FIRST), frequency(), 'GENE0'),
|
|
240
|
+
variant('causal b', counts(_SECOND), frequency(), 'GENE0')]
|
|
241
|
+
causal: Tuple[str, ...] = ('causal a', 'causal b')
|
|
242
|
+
else:
|
|
243
|
+
variants = [variant('causal', disease, frequency(), 'GENE0')]
|
|
244
|
+
causal = ('causal',)
|
|
245
|
+
# a rare variant the proband carries came into the family through one of the proband's founder ancestors
|
|
246
|
+
ancestors = pedigree.ancestors(proband) | {proband}
|
|
247
|
+
roots = [f for f in founders if f in ancestors]
|
|
248
|
+
made = 0
|
|
249
|
+
while made < background:
|
|
250
|
+
q = frequency()
|
|
251
|
+
copies = _drop(rows, x, {str(rng.choice(roots)): 1}, q, rng)
|
|
252
|
+
if copies[proband] == 0:
|
|
253
|
+
continue
|
|
254
|
+
made += 1
|
|
255
|
+
variants.append(variant(f'background {made}', copies, q, f'GENE{made}'))
|
|
256
|
+
made = 0
|
|
257
|
+
def founder_copies(pid, label):
|
|
258
|
+
return (label,) if x and sex[pid] == MALE else (label, 0)
|
|
259
|
+
|
|
260
|
+
while made < background_pairs:
|
|
261
|
+
one, other = str(rng.choice(roots)), str(rng.choice(roots))
|
|
262
|
+
if one != other:
|
|
263
|
+
start = {one: founder_copies(one, 1), other: founder_copies(other, 2)}
|
|
264
|
+
elif x and sex[one] == MALE:
|
|
265
|
+
start = {one: (3,)}
|
|
266
|
+
else:
|
|
267
|
+
# one founder with both: one on each copy of the gene, or both on one copy
|
|
268
|
+
start = {one: (1, 2) if rng.random() < 0.5 else (3, 0)}
|
|
269
|
+
haplotypes = _drop_haplotypes(rows, x, start, rng)
|
|
270
|
+
first, second = counts(_FIRST), counts(_SECOND)
|
|
271
|
+
if first[proband] == 0 or second[proband] == 0:
|
|
272
|
+
continue
|
|
273
|
+
made += 1
|
|
274
|
+
gene = f'PAIR{made}'
|
|
275
|
+
variants.append(variant(f'pair {made} a', first, frequency(), gene))
|
|
276
|
+
variants.append(variant(f'pair {made} b', second, frequency(), gene))
|
|
277
|
+
return SimulatedFamily(pedigree, mode, proband, disease, variants, seq, penetrance, phenocopy, causal)
|