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/inheritance.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""The mode of inheritance that best explains a family's phenotypes.
|
|
2
|
+
|
|
3
|
+
For each mode (autosomal dominant or recessive, X-linked recessive or dominant), PedNet works out the probability of
|
|
4
|
+
everyone's phenotype given that the proband has the disease genotype for that mode, summing over every genotype the
|
|
5
|
+
rest of the family could have. Conditioning on the proband takes account of how the family came to be seen, and
|
|
6
|
+
makes the answer depend little on how common the disease allele is. The penetrance isn't known, so the probability
|
|
7
|
+
is averaged over a range of penetrances, and if no proband is given it is averaged over each affected person as the
|
|
8
|
+
proband. With the modes' prior probabilities, Bayes' theorem then gives each mode's posterior probability.
|
|
9
|
+
"""
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Dict, List, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .engine import Network
|
|
16
|
+
from .models import MODE_NAMES, MODES, X_LINKED, Model, founder_prior, phenotype_factor
|
|
17
|
+
from .pedigree import Pedigree
|
|
18
|
+
|
|
19
|
+
# The penetrances averaged over, equally weighted, when it isn't known
|
|
20
|
+
DEFAULT_PENETRANCE = (0.6, 0.8, 0.95, 0.99)
|
|
21
|
+
DEFAULT_PHENOCOPY = 0.001
|
|
22
|
+
LN10 = np.log(10)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _logsumexp(x, axis=None):
|
|
26
|
+
x = np.asarray(x, dtype=float)
|
|
27
|
+
peak = np.max(x, axis=axis, keepdims=True)
|
|
28
|
+
peak = np.where(np.isfinite(peak), peak, 0.0)
|
|
29
|
+
with np.errstate(divide='ignore'):
|
|
30
|
+
out = np.log(np.sum(np.exp(x - peak), axis=axis, keepdims=True)) + peak
|
|
31
|
+
return np.squeeze(out, axis=axis) if axis is not None else float(out.reshape(()))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _weights(penetrance, weights) -> np.ndarray:
|
|
35
|
+
if np.isscalar(penetrance):
|
|
36
|
+
penetrance = (float(penetrance),)
|
|
37
|
+
w = np.ones(len(penetrance)) if weights is None else np.asarray(weights, dtype=float)
|
|
38
|
+
if len(w) != len(penetrance) or np.any(w < 0) or w.sum() == 0:
|
|
39
|
+
raise ValueError('penetrance_weights must be one non-negative weight for each penetrance')
|
|
40
|
+
return w / w.sum()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _prior(modes, prior) -> Dict[str, float]:
|
|
44
|
+
if prior is None:
|
|
45
|
+
return {m: 1 / len(modes) for m in modes}
|
|
46
|
+
total = sum(prior.get(m, 0) for m in modes)
|
|
47
|
+
if total <= 0:
|
|
48
|
+
raise ValueError('The prior must give some probability to one of the modes')
|
|
49
|
+
return {m: prior.get(m, 0) / total for m in modes}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _modes(modes) -> List[str]:
|
|
53
|
+
if isinstance(modes, str):
|
|
54
|
+
modes = [m.strip() for m in modes.replace(',', ' ').split()]
|
|
55
|
+
modes = [m.upper() for m in modes]
|
|
56
|
+
for m in modes:
|
|
57
|
+
if m not in MODES:
|
|
58
|
+
raise ValueError(f'Unknown mode of inheritance {m!r}: use one of {", ".join(MODES)}')
|
|
59
|
+
return modes
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ModeEvidence:
|
|
64
|
+
mode: str
|
|
65
|
+
posterior: float
|
|
66
|
+
log10_likelihood: float
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def name(self) -> str:
|
|
70
|
+
return MODE_NAMES[self.mode]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class InheritanceResult:
|
|
75
|
+
"""
|
|
76
|
+
The modes, most probable first. call is the most probable mode if its posterior probability reaches threshold,
|
|
77
|
+
and 'uncertain' otherwise. log10_likelihood is the log10 probability of the phenotypes under each mode (given
|
|
78
|
+
the proband), whose differences are log10 likelihood ratios.
|
|
79
|
+
"""
|
|
80
|
+
family: str
|
|
81
|
+
modes: List[ModeEvidence]
|
|
82
|
+
call: str
|
|
83
|
+
threshold: float
|
|
84
|
+
probands: List[str]
|
|
85
|
+
notes: List[str] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
def posterior(self, mode: str) -> float:
|
|
88
|
+
return next((m.posterior for m in self.modes if m.mode == mode), 0.0)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def dominant(self) -> float:
|
|
92
|
+
"""The probability that the condition is dominant (AD or XLD), among the modes weighed up"""
|
|
93
|
+
return self.posterior('AD') + self.posterior('XLD')
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def recessive(self) -> float:
|
|
97
|
+
"""The probability that it is recessive (AR or XLR)"""
|
|
98
|
+
return self.posterior('AR') + self.posterior('XLR')
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def x_linked(self) -> float:
|
|
102
|
+
"""The probability that it is X-linked (XLR or XLD)"""
|
|
103
|
+
return self.posterior('XLR') + self.posterior('XLD')
|
|
104
|
+
|
|
105
|
+
def table(self) -> List[dict]:
|
|
106
|
+
return [{'family': self.family, 'mode': m.mode, 'posterior': m.posterior, 'log10_likelihood': m.log10_likelihood}
|
|
107
|
+
for m in self.modes]
|
|
108
|
+
|
|
109
|
+
def __str__(self):
|
|
110
|
+
lines = [f'Family {self.family}: {self.call if self.call == "uncertain" else MODE_NAMES[self.call]}']
|
|
111
|
+
for m in self.modes:
|
|
112
|
+
lines.append(f' {m.mode:4s} {m.posterior:6.3f} ({m.name})')
|
|
113
|
+
names = {m.mode for m in self.modes}
|
|
114
|
+
if names & {'AD', 'XLD'} and names & {'AR', 'XLR'}:
|
|
115
|
+
lines.append(f' dominant {self.dominant:.3f}, recessive {self.recessive:.3f}')
|
|
116
|
+
if names & {'XLR', 'XLD'} and names & {'AD', 'AR'}:
|
|
117
|
+
lines.append(f' X-linked {self.x_linked:.3f}, autosomal {1 - self.x_linked:.3f}')
|
|
118
|
+
lines.extend(f' note: {n}' for n in self.notes)
|
|
119
|
+
return '\n'.join(lines)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def mode_log_likelihoods(pedigree: Pedigree, modes: Sequence[str] = MODES,
|
|
123
|
+
penetrance=DEFAULT_PENETRANCE, penetrance_weights=None,
|
|
124
|
+
phenocopy: float = DEFAULT_PHENOCOPY, allele_frequency: Optional[Dict[str, float]] = None,
|
|
125
|
+
carrier_penetrance: Optional[float] = None, probands: Optional[Sequence[str]] = None,
|
|
126
|
+
networks: Optional[Dict[bool, Network]] = None) -> Dict[str, float]:
|
|
127
|
+
"""
|
|
128
|
+
The natural log of P(phenotypes | proband has the disease genotype, mode) for each mode, averaged over the
|
|
129
|
+
penetrances (with penetrance_weights) and over the probands (by default everyone affected).
|
|
130
|
+
"""
|
|
131
|
+
modes = _modes(modes)
|
|
132
|
+
pens = (float(penetrance),) if np.isscalar(penetrance) else tuple(float(f) for f in penetrance)
|
|
133
|
+
with np.errstate(divide='ignore'):
|
|
134
|
+
log_w = np.log(_weights(pens, penetrance_weights))
|
|
135
|
+
probands = list(probands) if probands else pedigree.affected()
|
|
136
|
+
if not probands:
|
|
137
|
+
return {m: 0.0 for m in modes}
|
|
138
|
+
networks = {} if networks is None else networks
|
|
139
|
+
allele_frequency = allele_frequency or {}
|
|
140
|
+
result = {}
|
|
141
|
+
for mode in modes:
|
|
142
|
+
x = X_LINKED[mode]
|
|
143
|
+
if x not in networks:
|
|
144
|
+
networks[x] = Network(pedigree, x)
|
|
145
|
+
net = networks[x]
|
|
146
|
+
model = Model(mode, allele_frequency.get(mode), pens[0], phenocopy, carrier_penetrance)
|
|
147
|
+
F, A = len(pens), len(probands)
|
|
148
|
+
positions = [net.index(p) for p in probands]
|
|
149
|
+
with_phenotypes, without = [], []
|
|
150
|
+
for i, (kind, person) in enumerate(zip(net.kinds, pedigree.people)):
|
|
151
|
+
base = founder_prior(kind, model.allele_frequency) if net.parents[i] is None else np.ones(len(model.causal(kind)))
|
|
152
|
+
u = np.tile(base, (F * A, 1))
|
|
153
|
+
for fi, f in enumerate(pens):
|
|
154
|
+
u[fi * A:(fi + 1) * A] *= phenotype_factor(model.with_(penetrance=f), kind, person.phenotype)
|
|
155
|
+
u0 = np.tile(base, (A, 1))
|
|
156
|
+
for ai, pos in enumerate(positions):
|
|
157
|
+
if pos == i:
|
|
158
|
+
u[ai::A] *= model.causal(kind)
|
|
159
|
+
u0[ai] *= model.causal(kind)
|
|
160
|
+
with_phenotypes.append(u)
|
|
161
|
+
without.append(u0)
|
|
162
|
+
joint = net.log_likelihood(with_phenotypes).reshape(F, A)
|
|
163
|
+
proband_causal = net.log_likelihood(without)
|
|
164
|
+
with np.errstate(invalid='ignore'):
|
|
165
|
+
conditional = np.where(np.isfinite(proband_causal)[None, :], joint - proband_causal[None, :], -np.inf)
|
|
166
|
+
result[mode] = _logsumexp(conditional + log_w[:, None] - np.log(A))
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def infer_mode(pedigree: Pedigree, modes: Sequence[str] = MODES, penetrance=DEFAULT_PENETRANCE,
|
|
171
|
+
penetrance_weights=None, phenocopy: float = DEFAULT_PHENOCOPY,
|
|
172
|
+
allele_frequency: Optional[Dict[str, float]] = None, carrier_penetrance: Optional[float] = None,
|
|
173
|
+
prior: Optional[Dict[str, float]] = None, proband: Optional[str] = None,
|
|
174
|
+
threshold: float = 0.8) -> InheritanceResult:
|
|
175
|
+
"""
|
|
176
|
+
The posterior probability of each mode of inheritance for a pedigree.
|
|
177
|
+
|
|
178
|
+
pedigree: a Pedigree (see read_ped)
|
|
179
|
+
modes: the modes to weigh up, from 'AD', 'AR', 'XLR' and 'XLD'
|
|
180
|
+
penetrance: the penetrance, or several to average over (by default 0.6, 0.8, 0.95 and 0.99, equally weighted)
|
|
181
|
+
penetrance_weights: the weight of each penetrance, if not equal
|
|
182
|
+
phenocopy: the chance that someone without the disease genotype is affected
|
|
183
|
+
allele_frequency: the disease allele frequency for each mode, if not the defaults
|
|
184
|
+
carrier_penetrance: for X-linked recessive, the chance that a woman with one copy is affected
|
|
185
|
+
prior: the prior probability of each mode (equal by default)
|
|
186
|
+
proband: the person through whom the family was found; by default each affected person in turn
|
|
187
|
+
threshold: the posterior probability the most probable mode needs for the call not to be 'uncertain'
|
|
188
|
+
"""
|
|
189
|
+
modes = _modes(modes)
|
|
190
|
+
priors = _prior(modes, prior)
|
|
191
|
+
notes = list(pedigree.notes)
|
|
192
|
+
affected = pedigree.affected()
|
|
193
|
+
if proband is not None:
|
|
194
|
+
if proband not in pedigree:
|
|
195
|
+
raise ValueError(f'{proband} is not in family {pedigree.family}')
|
|
196
|
+
if not pedigree[proband].affected:
|
|
197
|
+
notes.append(f'the proband, {proband}, is not recorded as affected')
|
|
198
|
+
probands = [proband]
|
|
199
|
+
else:
|
|
200
|
+
probands = affected
|
|
201
|
+
if not affected:
|
|
202
|
+
notes.append('no one is affected, so the phenotypes say nothing about the mode of inheritance')
|
|
203
|
+
elif len(affected) == 1:
|
|
204
|
+
notes.append('only one person is affected: a new dominant mutation, which PedNet doesn\'t model, would '
|
|
205
|
+
'explain that as well as a recessive condition does')
|
|
206
|
+
x_needed = any(X_LINKED[m] for m in modes)
|
|
207
|
+
unknown_sex = [p.id for p in pedigree.people if p.sex == 0 and not p.placeholder]
|
|
208
|
+
if x_needed and unknown_sex:
|
|
209
|
+
notes.append(f'the sex of {", ".join(unknown_sex[:5])}{" and others" if len(unknown_sex) > 5 else ""} '
|
|
210
|
+
'is unknown, and is taken as equally likely to be either for the X-linked modes')
|
|
211
|
+
if probands:
|
|
212
|
+
logs = mode_log_likelihoods(pedigree, modes, penetrance, penetrance_weights, phenocopy, allele_frequency,
|
|
213
|
+
carrier_penetrance, probands)
|
|
214
|
+
else:
|
|
215
|
+
logs = {m: 0.0 for m in modes}
|
|
216
|
+
log_post = np.array([np.log(priors[m]) + logs[m] if priors[m] > 0 else -np.inf for m in modes])
|
|
217
|
+
total = _logsumexp(log_post)
|
|
218
|
+
possible = bool(np.isfinite(total))
|
|
219
|
+
post = np.exp(log_post - total) if possible else np.array([priors[m] for m in modes])
|
|
220
|
+
if probands and not possible:
|
|
221
|
+
notes.append('none of the modes allowed can give these phenotypes with this penetrance and phenocopy rate, '
|
|
222
|
+
'so the probabilities shown are the priors')
|
|
223
|
+
evidence = [ModeEvidence(m, float(post[k]), float(logs[m] / LN10)) for k, m in enumerate(modes)]
|
|
224
|
+
evidence.sort(key=lambda e: -e.posterior)
|
|
225
|
+
call = evidence[0].mode if probands and possible and evidence[0].posterior >= threshold else 'uncertain'
|
|
226
|
+
return InheritanceResult(pedigree.family, evidence, call, threshold, probands, notes)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def infer_shared_mode(pedigrees, modes: Sequence[str] = MODES, penetrance=DEFAULT_PENETRANCE,
|
|
230
|
+
penetrance_weights=None, phenocopy: float = DEFAULT_PHENOCOPY,
|
|
231
|
+
allele_frequency: Optional[Dict[str, float]] = None, carrier_penetrance: Optional[float] = None,
|
|
232
|
+
prior: Optional[Dict[str, float]] = None, threshold: float = 0.8) -> InheritanceResult:
|
|
233
|
+
"""
|
|
234
|
+
The posterior probability of each mode of inheritance for a condition that several families share, from the
|
|
235
|
+
product of the families' likelihoods (they are taken to be unrelated, with the same mode of inheritance, though
|
|
236
|
+
not necessarily the same gene). pedigrees is a dict of family to Pedigree, as read_ped gives, or a list of
|
|
237
|
+
pedigrees; the other arguments are as for infer_mode.
|
|
238
|
+
"""
|
|
239
|
+
if isinstance(pedigrees, dict):
|
|
240
|
+
pedigrees = list(pedigrees.values())
|
|
241
|
+
modes = _modes(modes)
|
|
242
|
+
priors = _prior(modes, prior)
|
|
243
|
+
totals = {m: 0.0 for m in modes}
|
|
244
|
+
notes, probands = [], []
|
|
245
|
+
for pedigree in pedigrees:
|
|
246
|
+
result = infer_mode(pedigree, modes, penetrance, penetrance_weights, phenocopy, allele_frequency,
|
|
247
|
+
carrier_penetrance, None, None, threshold)
|
|
248
|
+
for m in result.modes:
|
|
249
|
+
totals[m.mode] += m.log10_likelihood * LN10
|
|
250
|
+
notes.extend(f'family {pedigree.family}: {n}' for n in result.notes)
|
|
251
|
+
probands.extend(result.probands)
|
|
252
|
+
log_post = np.array([np.log(priors[m]) + totals[m] if priors[m] > 0 else -np.inf for m in modes])
|
|
253
|
+
total = _logsumexp(log_post)
|
|
254
|
+
possible = bool(np.isfinite(total))
|
|
255
|
+
post = np.exp(log_post - total) if possible else np.array([priors[m] for m in modes])
|
|
256
|
+
if probands and not possible:
|
|
257
|
+
notes.append('none of the modes allowed can give these phenotypes with this penetrance and phenocopy rate, '
|
|
258
|
+
'so the probabilities shown are the priors')
|
|
259
|
+
evidence = [ModeEvidence(m, float(post[k]), float(totals[m] / LN10)) for k, m in enumerate(modes)]
|
|
260
|
+
evidence.sort(key=lambda e: -e.posterior)
|
|
261
|
+
call = evidence[0].mode if probands and possible and evidence[0].posterior >= threshold else 'uncertain'
|
|
262
|
+
return InheritanceResult(', '.join(p.family for p in pedigrees), evidence, call, threshold, probands, notes)
|
pednet/models.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Modes of inheritance, and the genotype probabilities they give.
|
|
2
|
+
|
|
3
|
+
Each person's genotype at the locus is a variable with a few states. For an autosomal locus these are the number of
|
|
4
|
+
copies of the alternate (disease) allele, 0, 1 or 2 (kind "A"). On the X chromosome a man has one copy, 0 or 1
|
|
5
|
+
(kind "M"), and a woman two (kind "F"). Someone of unknown sex who has no children has the man's two states and the
|
|
6
|
+
woman's three, each equally likely beforehand (kind "U"); a parent's sex is always known from their role.
|
|
7
|
+
"""
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from typing import Dict, Optional
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
MODES = ('AD', 'AR', 'XLR', 'XLD')
|
|
15
|
+
MODE_NAMES = {'AD': 'autosomal dominant', 'AR': 'autosomal recessive',
|
|
16
|
+
'XLR': 'X-linked recessive', 'XLD': 'X-linked dominant'}
|
|
17
|
+
X_LINKED = {'AD': False, 'AR': False, 'XLR': True, 'XLD': True}
|
|
18
|
+
|
|
19
|
+
# the number of copies of the alternate allele in each state, and whether the state is a man's
|
|
20
|
+
ALT_COPIES = {'A': (0, 1, 2), 'M': (0, 1), 'F': (0, 1, 2), 'U': (0, 1, 0, 1, 2)}
|
|
21
|
+
IS_MALE = {'A': (None, None, None), 'M': (True, True), 'F': (False, False, False),
|
|
22
|
+
'U': (True, True, False, False, False)}
|
|
23
|
+
|
|
24
|
+
# Default disease allele frequencies. The mode of inheritance is judged given that the proband carries the disease
|
|
25
|
+
# genotype, which makes the results depend little on these, as long as the allele is rare.
|
|
26
|
+
DEFAULT_FREQUENCY = {'AD': 1e-4, 'AR': 1e-3, 'XLR': 5e-4, 'XLD': 1e-4}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def domain(kind: str) -> int:
|
|
30
|
+
return len(ALT_COPIES[kind])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Model:
|
|
35
|
+
"""
|
|
36
|
+
A mode of inheritance with its parameters:
|
|
37
|
+
|
|
38
|
+
mode: 'AD', 'AR', 'XLR' or 'XLD'
|
|
39
|
+
allele_frequency: the disease allele's frequency in the population
|
|
40
|
+
penetrance: the chance that someone with the disease genotype is affected
|
|
41
|
+
phenocopy: the chance that someone without it is affected all the same
|
|
42
|
+
carrier_penetrance: for XLR only, the chance that a woman with one copy is affected (by default the phenocopy rate)
|
|
43
|
+
"""
|
|
44
|
+
mode: str
|
|
45
|
+
allele_frequency: Optional[float] = None
|
|
46
|
+
penetrance: float = 0.95
|
|
47
|
+
phenocopy: float = 0.001
|
|
48
|
+
carrier_penetrance: Optional[float] = None
|
|
49
|
+
|
|
50
|
+
def __post_init__(self):
|
|
51
|
+
if self.mode not in MODES:
|
|
52
|
+
raise ValueError(f'Unknown mode of inheritance {self.mode!r}: use one of {", ".join(MODES)}')
|
|
53
|
+
if self.allele_frequency is None:
|
|
54
|
+
object.__setattr__(self, 'allele_frequency', DEFAULT_FREQUENCY[self.mode])
|
|
55
|
+
for name in ('allele_frequency', 'penetrance', 'phenocopy'):
|
|
56
|
+
value = getattr(self, name)
|
|
57
|
+
if not 0 <= value <= 1:
|
|
58
|
+
raise ValueError(f'{name} must be between 0 and 1, not {value}')
|
|
59
|
+
|
|
60
|
+
def with_(self, **changes) -> 'Model':
|
|
61
|
+
return replace(self, **changes)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def x_linked(self) -> bool:
|
|
65
|
+
return X_LINKED[self.mode]
|
|
66
|
+
|
|
67
|
+
def kinds(self):
|
|
68
|
+
return ('M', 'F', 'U') if self.x_linked else ('A',)
|
|
69
|
+
|
|
70
|
+
def affected_probability(self, kind: str) -> np.ndarray:
|
|
71
|
+
"""P(affected) for each state of a genotype of this kind"""
|
|
72
|
+
f, p = self.penetrance, self.phenocopy
|
|
73
|
+
h = p if self.carrier_penetrance is None else self.carrier_penetrance
|
|
74
|
+
if self.mode == 'AD':
|
|
75
|
+
return np.array([p, f, f])
|
|
76
|
+
if self.mode == 'AR':
|
|
77
|
+
return np.array([p, p, f])
|
|
78
|
+
male = np.array([p, f])
|
|
79
|
+
female = np.array([p, h, f]) if self.mode == 'XLR' else np.array([p, f, f])
|
|
80
|
+
return {'M': male, 'F': female, 'U': np.concatenate([male, female])}[kind]
|
|
81
|
+
|
|
82
|
+
def causal(self, kind: str) -> np.ndarray:
|
|
83
|
+
"""Which states of a genotype of this kind are the disease genotype (as 1.0 or 0.0)"""
|
|
84
|
+
if self.mode == 'AD':
|
|
85
|
+
return np.array([0., 1., 1.])
|
|
86
|
+
if self.mode == 'AR':
|
|
87
|
+
return np.array([0., 0., 1.])
|
|
88
|
+
male = np.array([0., 1.])
|
|
89
|
+
female = np.array([0., 0., 1.]) if self.mode == 'XLR' else np.array([0., 1., 1.])
|
|
90
|
+
return {'M': male, 'F': female, 'U': np.concatenate([male, female])}[kind]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def founder_prior(kind: str, q) -> np.ndarray:
|
|
94
|
+
"""
|
|
95
|
+
The genotype probabilities of someone with no parents in the pedigree, in Hardy-Weinberg proportions.
|
|
96
|
+
q may be a single allele frequency or an array of them (one per batch), giving shape (batch, states).
|
|
97
|
+
"""
|
|
98
|
+
q = np.asarray(q, dtype=float)
|
|
99
|
+
p = 1 - q
|
|
100
|
+
diploid = np.stack([p * p, 2 * p * q, q * q], axis=-1)
|
|
101
|
+
haploid = np.stack([p, q], axis=-1)
|
|
102
|
+
if kind in ('A', 'F'):
|
|
103
|
+
return diploid
|
|
104
|
+
if kind == 'M':
|
|
105
|
+
return haploid
|
|
106
|
+
return np.concatenate([0.5 * haploid, 0.5 * diploid], axis=-1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def phenotype_factor(model: Model, kind: str, phenotype: int) -> np.ndarray:
|
|
110
|
+
"""P(the person's phenotype) for each state: 2 affected, 1 unaffected, anything else unknown"""
|
|
111
|
+
affected = model.affected_probability(kind)
|
|
112
|
+
if phenotype == 2:
|
|
113
|
+
return affected
|
|
114
|
+
if phenotype == 1:
|
|
115
|
+
return 1 - affected
|
|
116
|
+
return np.ones(domain(kind))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def genotype_factor(kind: str, alt_copies: Optional[int], error_rate: float = 1e-4) -> np.ndarray:
|
|
120
|
+
"""
|
|
121
|
+
P(the observed genotype) for each state, allowing for genotyping errors. alt_copies is the number of copies of the
|
|
122
|
+
alternate allele called (0, 1 or 2), or None if the person wasn't genotyped.
|
|
123
|
+
|
|
124
|
+
A call one copy out (a heterozygote called homozygous, or the other way) happens with probability error_rate,
|
|
125
|
+
and one two copies out (a homozygote called the other homozygote) with probability error_rate squared, so that a
|
|
126
|
+
rare variant called homozygous in two affected siblings isn't put down to two errors. A man's X-chromosome call
|
|
127
|
+
counts as the alternate allele if it has any copy of it; calling his allele when he doesn't have it is a single
|
|
128
|
+
error, and missing it when he does is a double one, since he has nothing else at that position.
|
|
129
|
+
"""
|
|
130
|
+
states = ALT_COPIES[kind]
|
|
131
|
+
if alt_copies is None:
|
|
132
|
+
return np.ones(len(states))
|
|
133
|
+
return np.array([call_probability(copies, male, alt_copies, error_rate)
|
|
134
|
+
for copies, male in zip(states, IS_MALE[kind])])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@lru_cache(maxsize=None)
|
|
138
|
+
def genotype_table(kind: str, error_rate: float) -> np.ndarray:
|
|
139
|
+
"""
|
|
140
|
+
genotype_factor for every call, as rows: not genotyped, then 0, 1 and 2 copies called, to be indexed by
|
|
141
|
+
call_codes. The table is shared, so it is read-only.
|
|
142
|
+
"""
|
|
143
|
+
table = np.stack([genotype_factor(kind, called, error_rate) for called in (None, 0, 1, 2)])
|
|
144
|
+
table.flags.writeable = False
|
|
145
|
+
return table
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def call_codes(variants, person_id: str) -> np.ndarray:
|
|
149
|
+
"""Each variant's genotype call in one person as a row of genotype_table: 0 if not genotyped, else copies + 1"""
|
|
150
|
+
return np.array([0 if g is None else min(int(g), 2) + 1
|
|
151
|
+
for g in (v.genotypes.get(person_id) for v in variants)], dtype=np.intp)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def call_probability(copies: int, male: Optional[bool], called: int, error_rate: float) -> float:
|
|
155
|
+
"""
|
|
156
|
+
The chance of a genotype call of called copies of the alternate allele from someone with copies of it, for a
|
|
157
|
+
man's X chromosome (male True) or a pair of chromosomes (see genotype_factor)
|
|
158
|
+
"""
|
|
159
|
+
e = error_rate
|
|
160
|
+
if male:
|
|
161
|
+
if called >= 1:
|
|
162
|
+
return 1 - e * e if copies == 1 else e
|
|
163
|
+
return 1 - e if copies == 0 else e * e
|
|
164
|
+
off = abs(called - copies)
|
|
165
|
+
if off == 0:
|
|
166
|
+
return 1 - 2 * e if copies == 1 else 1 - e - e * e
|
|
167
|
+
return e if off == 1 else e * e
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _passes(copies: int) -> float:
|
|
171
|
+
"""The chance that a parent with this many copies passes one on (a man passes his X to every daughter)"""
|
|
172
|
+
return copies / 2
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def transmission(kind: str) -> np.ndarray:
|
|
176
|
+
"""
|
|
177
|
+
P(child's state | father's state, mother's state), as an array indexed [child, father, mother].
|
|
178
|
+
For an autosomal locus all three are of kind A. On the X chromosome, the father is of kind M and the mother of
|
|
179
|
+
kind F, and the child of the kind given (M a son, F a daughter, U unknown sex).
|
|
180
|
+
"""
|
|
181
|
+
if kind == 'A':
|
|
182
|
+
t = np.zeros((3, 3, 3))
|
|
183
|
+
for f in range(3):
|
|
184
|
+
for m in range(3):
|
|
185
|
+
a, b = _passes(f), _passes(m)
|
|
186
|
+
t[2, f, m] = a * b
|
|
187
|
+
t[1, f, m] = a * (1 - b) + (1 - a) * b
|
|
188
|
+
t[0, f, m] = (1 - a) * (1 - b)
|
|
189
|
+
return t
|
|
190
|
+
son = np.zeros((2, 2, 3))
|
|
191
|
+
daughter = np.zeros((3, 2, 3))
|
|
192
|
+
for h in range(2):
|
|
193
|
+
for m in range(3):
|
|
194
|
+
b = _passes(m)
|
|
195
|
+
son[1, h, m], son[0, h, m] = b, 1 - b
|
|
196
|
+
# a daughter has her father's only X, and one of her mother's two
|
|
197
|
+
daughter[h + 1, h, m] += b
|
|
198
|
+
daughter[h, h, m] += 1 - b
|
|
199
|
+
if kind == 'M':
|
|
200
|
+
return son
|
|
201
|
+
if kind == 'F':
|
|
202
|
+
return daughter
|
|
203
|
+
return np.concatenate([0.5 * son, 0.5 * daughter], axis=0)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def default_models(modes=MODES, penetrance: float = 0.95, phenocopy: float = 0.001,
|
|
207
|
+
allele_frequency: Optional[Dict[str, float]] = None) -> Dict[str, Model]:
|
|
208
|
+
allele_frequency = allele_frequency or {}
|
|
209
|
+
return {m: Model(m, allele_frequency.get(m), penetrance, phenocopy) for m in modes}
|