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