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/engine.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Exact pedigree likelihoods by variable elimination.
|
|
2
|
+
|
|
3
|
+
The pedigree is a Bayesian network: each person's genotype depends on their parents' genotypes (Mendel's laws),
|
|
4
|
+
and their phenotype and genotype calls depend on their own genotype. The likelihood of what's observed is the sum,
|
|
5
|
+
over every possible assignment of genotypes, of the product of these probabilities. Variable elimination sums the
|
|
6
|
+
genotypes out one at a time, in an order chosen to keep the intermediate tables small. Unlike the Elston-Stewart
|
|
7
|
+
peeling of a family tree, it is exact for pedigrees with loops too (consanguinity, or two families married into each
|
|
8
|
+
other twice), at a cost that grows with how tangled the loops are rather than with the size of the pedigree.
|
|
9
|
+
|
|
10
|
+
Many likelihoods are computed at once: each person's "unary" factor (their founder prior, phenotype probabilities,
|
|
11
|
+
genotype evidence) can carry a leading batch axis, so that one pass gives the likelihood for each of many
|
|
12
|
+
penetrances, probands or variants. A large batch is split up so that no table grows too large for memory.
|
|
13
|
+
|
|
14
|
+
The genotype at the locus is a variable whose states the locus defines: by default the number of copies of one
|
|
15
|
+
alternate allele (see models), and for a pair of variants in one gene, the pair of haplotypes a person carries
|
|
16
|
+
(see compound).
|
|
17
|
+
"""
|
|
18
|
+
import math
|
|
19
|
+
import string
|
|
20
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from .models import domain, transmission
|
|
25
|
+
from .pedigree import FEMALE, MALE, Pedigree
|
|
26
|
+
|
|
27
|
+
_LETTERS = string.ascii_letters
|
|
28
|
+
# The most numbers a table may hold (batch included) before a batch is split up: 4 million, or 32 MB
|
|
29
|
+
MAX_TABLE = 4_000_000
|
|
30
|
+
# The largest table a pedigree may need for one likelihood: beyond it, the pedigree has too many loops
|
|
31
|
+
MAX_STATES = 50_000_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Plan:
|
|
35
|
+
"""
|
|
36
|
+
The order in which to sum out the genotypes, and which tables each step combines. feasible is whether the
|
|
37
|
+
largest table is small enough for exact likelihoods; if it isn't, a ValueError is raised, unless check is False.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, scopes: Sequence[Tuple[int, ...]], domains: Sequence[int], check: bool = True):
|
|
41
|
+
n = len(domains)
|
|
42
|
+
factor_vars: Dict[int, Tuple[int, ...]] = {f: tuple(s) for f, s in enumerate(scopes)}
|
|
43
|
+
var_factors: Dict[int, set] = {v: set() for v in range(n)}
|
|
44
|
+
neighbours: Dict[int, set] = {v: set() for v in range(n)}
|
|
45
|
+
for f, s in factor_vars.items():
|
|
46
|
+
for v in s:
|
|
47
|
+
var_factors[v].add(f)
|
|
48
|
+
neighbours[v].update(u for u in s if u != v)
|
|
49
|
+
self.steps: List[Tuple[Tuple[int, ...], int, Tuple[int, ...], int]] = []
|
|
50
|
+
self.width = 0 # the most genotypes in one step
|
|
51
|
+
self.largest = 1 # the most states in one step's table
|
|
52
|
+
next_factor = len(scopes)
|
|
53
|
+
remaining = set(range(n))
|
|
54
|
+
cache: Dict[int, Tuple[int, float]] = {}
|
|
55
|
+
|
|
56
|
+
def score(v):
|
|
57
|
+
# the fill-in a step would add (new pairs of neighbours), then the size of the table it would make
|
|
58
|
+
if v not in cache:
|
|
59
|
+
nb = sorted(neighbours[v])
|
|
60
|
+
fill = sum(1 for i, a in enumerate(nb) for b in nb[i + 1:] if b not in neighbours[a])
|
|
61
|
+
size = float(np.prod([domains[u] for u in nb])) if nb else 1.0
|
|
62
|
+
cache[v] = (fill, size)
|
|
63
|
+
return cache[v] + (v,)
|
|
64
|
+
|
|
65
|
+
while remaining:
|
|
66
|
+
v = min(remaining, key=score)
|
|
67
|
+
involved = tuple(sorted(var_factors[v]))
|
|
68
|
+
union = sorted(set().union(*(factor_vars[f] for f in involved)) | {v})
|
|
69
|
+
out = tuple(u for u in union if u != v)
|
|
70
|
+
self.width = max(self.width, len(union))
|
|
71
|
+
# math.prod, whose whole numbers can't overflow as NumPy's 64-bit ones would for a very wide table
|
|
72
|
+
self.largest = max(self.largest, math.prod(domains[u] for u in union))
|
|
73
|
+
self.steps.append((involved, v, out, next_factor))
|
|
74
|
+
for f in involved:
|
|
75
|
+
for u in factor_vars.pop(f):
|
|
76
|
+
var_factors[u].discard(f)
|
|
77
|
+
factor_vars[next_factor] = out
|
|
78
|
+
for u in out:
|
|
79
|
+
var_factors[u].add(next_factor)
|
|
80
|
+
touched = set(neighbours[v])
|
|
81
|
+
for u in out:
|
|
82
|
+
neighbours[u].update(w for w in out if w != u)
|
|
83
|
+
neighbours[u].discard(v)
|
|
84
|
+
del neighbours[v]
|
|
85
|
+
remaining.discard(v)
|
|
86
|
+
for u in touched:
|
|
87
|
+
cache.pop(u, None)
|
|
88
|
+
for w in neighbours.get(u, ()):
|
|
89
|
+
cache.pop(w, None)
|
|
90
|
+
next_factor += 1
|
|
91
|
+
self.feasible = self.width <= len(_LETTERS) and self.largest <= MAX_STATES
|
|
92
|
+
if check and not self.feasible:
|
|
93
|
+
raise ValueError('This pedigree has too many loops for exact likelihoods: summing out its genotypes '
|
|
94
|
+
f'needs a table of {self.largest:,} states')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _log(x):
|
|
98
|
+
with np.errstate(divide='ignore'):
|
|
99
|
+
return np.log(x)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Network:
|
|
103
|
+
"""
|
|
104
|
+
The genotype network of one pedigree, for an autosomal or an X-linked locus.
|
|
105
|
+
|
|
106
|
+
people's genotype kinds: A (autosomal); on the X, M for men, F for women and U for anyone of unknown sex (who,
|
|
107
|
+
having no children, can be summed over as either).
|
|
108
|
+
|
|
109
|
+
locus, if given, defines the genotypes' states instead of the single biallelic variant of models: an object with
|
|
110
|
+
domain(kind), the number of states, and transmission(kind), the [child, father, mother] table. check False makes
|
|
111
|
+
a network whose plan can be looked at even when it is too large to work with (see Plan).
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, pedigree: Pedigree, x_linked: bool, locus=None, check: bool = True):
|
|
115
|
+
self.pedigree = pedigree
|
|
116
|
+
self.x_linked = x_linked
|
|
117
|
+
self.ids = pedigree.ids
|
|
118
|
+
index = {pid: i for i, pid in enumerate(self.ids)}
|
|
119
|
+
self.kinds: List[str] = []
|
|
120
|
+
for p in pedigree.people:
|
|
121
|
+
if not x_linked:
|
|
122
|
+
self.kinds.append('A')
|
|
123
|
+
elif p.sex == MALE:
|
|
124
|
+
self.kinds.append('M')
|
|
125
|
+
elif p.sex == FEMALE:
|
|
126
|
+
self.kinds.append('F')
|
|
127
|
+
else:
|
|
128
|
+
self.kinds.append('U')
|
|
129
|
+
size = domain if locus is None else locus.domain
|
|
130
|
+
make = transmission if locus is None else locus.transmission
|
|
131
|
+
self.domains = [size(k) for k in self.kinds]
|
|
132
|
+
self.parents: List[Optional[Tuple[int, int]]] = []
|
|
133
|
+
scopes: List[Tuple[int, ...]] = []
|
|
134
|
+
self._transmission: Dict[int, np.ndarray] = {}
|
|
135
|
+
for i, p in enumerate(pedigree.people):
|
|
136
|
+
if p.father is None:
|
|
137
|
+
self.parents.append(None)
|
|
138
|
+
continue
|
|
139
|
+
f, m = index[p.father], index[p.mother]
|
|
140
|
+
self.parents.append((f, m))
|
|
141
|
+
self._transmission[len(scopes)] = make(self.kinds[i])
|
|
142
|
+
scopes.append((i, f, m))
|
|
143
|
+
self._n_transmission = len(scopes)
|
|
144
|
+
scopes.extend((i,) for i in range(len(self.ids)))
|
|
145
|
+
self.scopes = scopes
|
|
146
|
+
self.plan = Plan(scopes, self.domains, check)
|
|
147
|
+
self.founders = [i for i, par in enumerate(self.parents) if par is None]
|
|
148
|
+
|
|
149
|
+
def index(self, person_id: str) -> int:
|
|
150
|
+
return self.ids.index(person_id)
|
|
151
|
+
|
|
152
|
+
def log_likelihood(self, unary: Sequence[np.ndarray]) -> np.ndarray:
|
|
153
|
+
"""
|
|
154
|
+
The natural log of the likelihood, given each person's unary factor: an array of shape (states,) or
|
|
155
|
+
(batch, states), the product of whatever applies to that person alone (founder prior, phenotype
|
|
156
|
+
probabilities, genotype evidence). Returns an array of shape (batch,).
|
|
157
|
+
"""
|
|
158
|
+
unary = [np.asarray(u, dtype=float) for u in unary]
|
|
159
|
+
batch = max((u.shape[0] for u in unary if u.ndim == 2), default=1)
|
|
160
|
+
step = max(1, MAX_TABLE // self.plan.largest)
|
|
161
|
+
if batch <= step:
|
|
162
|
+
return self._log_likelihood(unary, batch)
|
|
163
|
+
parts = []
|
|
164
|
+
for start in range(0, batch, step):
|
|
165
|
+
stop = min(start + step, batch)
|
|
166
|
+
parts.append(self._log_likelihood([u[start:stop] if u.ndim == 2 and u.shape[0] == batch else u
|
|
167
|
+
for u in unary], stop - start))
|
|
168
|
+
return np.concatenate(parts)
|
|
169
|
+
|
|
170
|
+
def _log_likelihood(self, unary: Sequence[np.ndarray], batch: int) -> np.ndarray:
|
|
171
|
+
tables: Dict[int, np.ndarray] = dict(self._transmission)
|
|
172
|
+
for i, u in enumerate(unary):
|
|
173
|
+
u = np.broadcast_to(u.reshape(-1, u.shape[-1]), (batch, u.shape[-1]))
|
|
174
|
+
tables[self._n_transmission + i] = u
|
|
175
|
+
scopes = {f: s for f, s in enumerate(self.scopes)}
|
|
176
|
+
is_batched = {f: f >= self._n_transmission for f in scopes}
|
|
177
|
+
log_scale = np.zeros(batch)
|
|
178
|
+
for involved, var, out, new in self.plan.steps:
|
|
179
|
+
union = sorted(set().union(*(scopes[f] for f in involved)) | {var})
|
|
180
|
+
letter = {v: _LETTERS[k] for k, v in enumerate(union)}
|
|
181
|
+
operands, terms = [], []
|
|
182
|
+
any_batched = False
|
|
183
|
+
for f in involved:
|
|
184
|
+
operands.append(tables.pop(f))
|
|
185
|
+
prefix = '...' if is_batched[f] else ''
|
|
186
|
+
any_batched |= is_batched[f]
|
|
187
|
+
terms.append(prefix + ''.join(letter[v] for v in scopes.pop(f)))
|
|
188
|
+
del is_batched[f]
|
|
189
|
+
result = np.einsum(','.join(terms) + '->' + ('...' if any_batched else '') + ''.join(letter[v] for v in out),
|
|
190
|
+
*operands)
|
|
191
|
+
if any_batched:
|
|
192
|
+
flat = result.reshape(batch, -1) if out else result.reshape(batch, 1)
|
|
193
|
+
peak = flat.max(axis=1)
|
|
194
|
+
safe = np.where(peak > 0, peak, 1.0)
|
|
195
|
+
result = result / safe.reshape((batch,) + (1,) * len(out))
|
|
196
|
+
log_scale += _log(peak)
|
|
197
|
+
tables[new] = result
|
|
198
|
+
scopes[new] = out
|
|
199
|
+
is_batched[new] = any_batched
|
|
200
|
+
total = np.ones(batch)
|
|
201
|
+
for f, t in tables.items():
|
|
202
|
+
total = total * (t if is_batched[f] else float(t))
|
|
203
|
+
return log_scale + _log(total)
|