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/features.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""Graph features of who is affected, and what they say about the mode of inheritance.
|
|
2
|
+
|
|
3
|
+
Before any genotypes are known, the way the affected people sit in the pedigree graph says much about how a
|
|
4
|
+
condition is inherited: whether it passes from parent to child down the generations, from father to son, through
|
|
5
|
+
unaffected women, or appears in the children of unaffected or related parents. This module measures that on the
|
|
6
|
+
graph, in two ways, each with a classifier of the mode of inheritance built on it:
|
|
7
|
+
|
|
8
|
+
- network_metrics: the four network metrics of the original PedNet (Nick Taylor's): how often an affected person has
|
|
9
|
+
an affected parent, how often an affected person's siblings are affected, how many generations are affected, and
|
|
10
|
+
how central unaffected people are among the affected. classify_by_metrics is the original classifier, which sets
|
|
11
|
+
a threshold on each metric from simulated families (by Youden's J) and lets the metrics vote, AD against AR.
|
|
12
|
+
- pattern_features: the patterns clinical geneticists read from a pedigree, as features of the graph: transmission
|
|
13
|
+
down the generations, father to son, father to daughters, mother to sons, skipped generations, the share of men
|
|
14
|
+
among the affected, affected children of related parents, and how much of the affected people's kinship is on
|
|
15
|
+
the X. classify_by_patterns is a multinomial logistic regression on them, for all four modes.
|
|
16
|
+
|
|
17
|
+
segregation_network_score is the original PedNet's network score of a candidate variant, from how its genotypes sit
|
|
18
|
+
in the graph (see the module legacy/SegScoreMain.py).
|
|
19
|
+
|
|
20
|
+
The likelihood methods (inheritance and segregation) use the same graph as a Bayesian network of genotypes. They
|
|
21
|
+
need no training, and what they assume about the condition (its penetrance and phenocopy rate) can be set to suit it,
|
|
22
|
+
where the classifiers have only what they learnt from simulated families. benchmarks/RESULTS.md compares them.
|
|
23
|
+
"""
|
|
24
|
+
from collections import deque
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
import networkx as nx
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from .graph import kinship_matrix, pedigree_graph
|
|
32
|
+
from .models import MODE_NAMES, MODES
|
|
33
|
+
from .pedigree import FEMALE, MALE, Pedigree
|
|
34
|
+
|
|
35
|
+
# -- the original PedNet's network metrics --------------------------------------------------------------------------
|
|
36
|
+
METRICS = ('ratio_aff_parent', 'sibling_aff_ratio', 'gen_cov', 'avg_bet_unaff')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def metric_graph(pedigree: Pedigree) -> nx.DiGraph:
|
|
40
|
+
"""
|
|
41
|
+
The graph the original PedNet measured: the pedigree DAG without the unknown parents PedNet adds for someone with
|
|
42
|
+
only one parent (who had one parent edge in the original), and without people who have no parents or children
|
|
43
|
+
in the pedigree
|
|
44
|
+
"""
|
|
45
|
+
graph = pedigree_graph(pedigree)
|
|
46
|
+
graph.remove_nodes_from([n for n in list(graph) if graph.degree(n) == 0])
|
|
47
|
+
return graph
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def graph_generations(graph: nx.DiGraph) -> Dict[str, int]:
|
|
51
|
+
"""
|
|
52
|
+
Each person's generation in a pedigree graph, 0 for the oldest, as Pedigree.generations works it out: a child
|
|
53
|
+
one generation below its parents, partners in one generation; where that can't hold throughout, a child one
|
|
54
|
+
below its lower parent, and someone with no parents one above their highest child
|
|
55
|
+
"""
|
|
56
|
+
level_of: Dict[str, int] = {}
|
|
57
|
+
order = list(nx.topological_sort(graph))
|
|
58
|
+
for component in nx.weakly_connected_components(graph):
|
|
59
|
+
people = [n for n in order if n in component]
|
|
60
|
+
level = {people[0]: 0}
|
|
61
|
+
queue = deque([people[0]])
|
|
62
|
+
consistent = True
|
|
63
|
+
while queue:
|
|
64
|
+
n = queue.popleft()
|
|
65
|
+
for other, step in [(c, 1) for c in graph.successors(n)] + [(p, -1) for p in graph.predecessors(n)]:
|
|
66
|
+
if other not in level:
|
|
67
|
+
level[other] = level[n] + step
|
|
68
|
+
queue.append(other)
|
|
69
|
+
elif level[other] != level[n] + step:
|
|
70
|
+
consistent = False
|
|
71
|
+
if not consistent:
|
|
72
|
+
level = {}
|
|
73
|
+
for n in people:
|
|
74
|
+
parents = list(graph.predecessors(n))
|
|
75
|
+
level[n] = max(level[p] for p in parents) + 1 if parents else 0
|
|
76
|
+
for n in people:
|
|
77
|
+
children = list(graph.successors(n))
|
|
78
|
+
if not list(graph.predecessors(n)) and children:
|
|
79
|
+
level[n] = min(level[c] for c in children) - 1
|
|
80
|
+
oldest = min(level.values())
|
|
81
|
+
for n, value in level.items():
|
|
82
|
+
level_of[n] = value - oldest
|
|
83
|
+
return level_of
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _affected(graph) -> List[str]:
|
|
87
|
+
return [n for n in graph if graph.nodes[n]['phenotype'] == 2]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _unaffected(graph) -> List[str]:
|
|
91
|
+
return [n for n in graph if graph.nodes[n]['phenotype'] == 1]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _siblings(graph, n) -> set:
|
|
95
|
+
sibs = set()
|
|
96
|
+
for parent in graph.predecessors(n):
|
|
97
|
+
sibs.update(graph.successors(parent))
|
|
98
|
+
sibs.discard(n)
|
|
99
|
+
return sibs
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def network_metrics(pedigree: Pedigree) -> Dict[str, float]:
|
|
103
|
+
"""
|
|
104
|
+
The original PedNet's four network metrics, each between 0 and 1 except the last:
|
|
105
|
+
|
|
106
|
+
- ratio_aff_parent: the share of affected people with an affected parent
|
|
107
|
+
- sibling_aff_ratio: of the siblings of affected people (counted once for each affected sibling), the share
|
|
108
|
+
affected
|
|
109
|
+
- gen_cov: the share of the generations with someone affected in them
|
|
110
|
+
- avg_bet_unaff: for each unaffected person, their betweenness (not normalised) in the graph of the affected
|
|
111
|
+
people and them alone, averaged over the unaffected people
|
|
112
|
+
"""
|
|
113
|
+
graph = metric_graph(pedigree)
|
|
114
|
+
affected, unaffected = _affected(graph), _unaffected(graph)
|
|
115
|
+
affected_set = set(affected)
|
|
116
|
+
with_affected_parent = sum(1 for n in affected
|
|
117
|
+
if any(graph.nodes[p]['phenotype'] == 2 for p in graph.predecessors(n)))
|
|
118
|
+
pairs = both = 0
|
|
119
|
+
for n in affected:
|
|
120
|
+
for sib in _siblings(graph, n):
|
|
121
|
+
pairs += 1
|
|
122
|
+
both += sib in affected_set
|
|
123
|
+
generations = graph_generations(graph) if len(graph) else {}
|
|
124
|
+
covered = len({generations[n] for n in affected}) / (max(generations.values()) + 1) if generations else 0.0
|
|
125
|
+
betweenness = []
|
|
126
|
+
for n in unaffected:
|
|
127
|
+
sub = graph.subgraph(affected + [n])
|
|
128
|
+
betweenness.append(nx.betweenness_centrality(sub, normalized=False)[n])
|
|
129
|
+
return {'ratio_aff_parent': with_affected_parent / len(affected) if affected else 0.0,
|
|
130
|
+
'sibling_aff_ratio': both / pairs if pairs else 0.0,
|
|
131
|
+
'gen_cov': covered,
|
|
132
|
+
'avg_bet_unaff': float(np.mean(betweenness)) if betweenness else 0.0}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class MetricThreshold:
|
|
137
|
+
"""A metric's threshold, the mode above it ('AD' or 'AR'), and the area under its ROC curve in training"""
|
|
138
|
+
threshold: float
|
|
139
|
+
high: str
|
|
140
|
+
auc: float
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _roc(scores: np.ndarray, positive: np.ndarray):
|
|
144
|
+
"""The ROC curve as scikit-learn's roc_curve makes it (dropping points that aren't corners): fpr, tpr, thresholds"""
|
|
145
|
+
order = np.argsort(-scores, kind='mergesort')
|
|
146
|
+
scores, positive = scores[order], positive[order]
|
|
147
|
+
last = np.r_[np.where(np.diff(scores))[0], len(scores) - 1]
|
|
148
|
+
tps = np.cumsum(positive)[last].astype(float)
|
|
149
|
+
fps = 1 + last - tps
|
|
150
|
+
thresholds = scores[last]
|
|
151
|
+
if len(tps) > 2:
|
|
152
|
+
keep = np.where(np.r_[True, np.logical_or(np.diff(fps, 2), np.diff(tps, 2)), True])[0]
|
|
153
|
+
fps, tps, thresholds = fps[keep], tps[keep], thresholds[keep]
|
|
154
|
+
tps, fps = np.r_[0, tps], np.r_[0, fps]
|
|
155
|
+
thresholds = np.r_[np.inf, thresholds]
|
|
156
|
+
return fps / max(fps[-1], 1), tps / max(tps[-1], 1), thresholds
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _auc(fpr, tpr) -> float:
|
|
160
|
+
return float(np.sum(np.diff(fpr) * (tpr[1:] + tpr[:-1]) / 2))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def train_metric_classifier(families: Iterable[Tuple[Pedigree, str]],
|
|
164
|
+
min_auc: float = 0.7) -> Dict[str, MetricThreshold]:
|
|
165
|
+
"""
|
|
166
|
+
The original PedNet's training: for each metric, the threshold with the largest Youden's J (true positive rate
|
|
167
|
+
less false positive rate) on the ROC curve of AD against AR families, keeping the metrics whose area under the
|
|
168
|
+
curve is at least min_auc. families are (pedigree, mode) pairs, with modes 'AD' and 'AR'.
|
|
169
|
+
"""
|
|
170
|
+
rows, modes = [], []
|
|
171
|
+
for pedigree, mode in families:
|
|
172
|
+
rows.append(network_metrics(pedigree))
|
|
173
|
+
modes.append(mode)
|
|
174
|
+
modes = np.array(modes)
|
|
175
|
+
if not {'AD', 'AR'} <= set(modes):
|
|
176
|
+
raise ValueError('Training needs both AD and AR families')
|
|
177
|
+
found = {}
|
|
178
|
+
for metric in METRICS:
|
|
179
|
+
values = np.array([r[metric] for r in rows], dtype=float)
|
|
180
|
+
high = 'AD' if values[modes == 'AD'].mean() > values[modes == 'AR'].mean() else 'AR'
|
|
181
|
+
fpr, tpr, thresholds = _roc(values, modes == high)
|
|
182
|
+
area = _auc(fpr, tpr)
|
|
183
|
+
if area >= min_auc:
|
|
184
|
+
found[metric] = MetricThreshold(float(thresholds[int(np.argmax(tpr - fpr))]), high, area)
|
|
185
|
+
return found
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def affected_child_of_unaffected_parents(pedigree: Pedigree) -> bool:
|
|
189
|
+
"""Whether some affected child has two parents in the pedigree, neither of them affected"""
|
|
190
|
+
graph = metric_graph(pedigree)
|
|
191
|
+
for n in _affected(graph):
|
|
192
|
+
parents = list(graph.predecessors(n))
|
|
193
|
+
if len(parents) == 2 and not any(graph.nodes[p]['phenotype'] == 2 for p in parents):
|
|
194
|
+
return True
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def classify_by_metrics(pedigree: Pedigree, thresholds: Optional[Dict[str, MetricThreshold]] = None,
|
|
199
|
+
confidence: float = 0.75) -> str:
|
|
200
|
+
"""
|
|
201
|
+
The original PedNet's classifier: 'AD', 'AR' or 'uncertain'. An affected child of unaffected parents gives two
|
|
202
|
+
votes for AR, and otherwise there is one for AD; each metric with a threshold votes for the mode on its side of
|
|
203
|
+
it. A mode is called if its votes are at least confidence times the number of voters. thresholds are by default
|
|
204
|
+
those trained on simulated families (see benchmarks/train_classifiers.py).
|
|
205
|
+
"""
|
|
206
|
+
if thresholds is None:
|
|
207
|
+
from ._trained import METRIC_THRESHOLDS
|
|
208
|
+
thresholds = {k: MetricThreshold(*v) for k, v in METRIC_THRESHOLDS.items()}
|
|
209
|
+
ad = ar = 0
|
|
210
|
+
total = 1
|
|
211
|
+
if affected_child_of_unaffected_parents(pedigree):
|
|
212
|
+
ar += 2
|
|
213
|
+
else:
|
|
214
|
+
ad += 1
|
|
215
|
+
values = network_metrics(pedigree)
|
|
216
|
+
for metric, t in thresholds.items():
|
|
217
|
+
total += 1
|
|
218
|
+
above = values[metric] >= t.threshold
|
|
219
|
+
if (t.high == 'AD') == above:
|
|
220
|
+
ad += 1
|
|
221
|
+
else:
|
|
222
|
+
ar += 1
|
|
223
|
+
if ad / total >= confidence:
|
|
224
|
+
return 'AD'
|
|
225
|
+
if ar / total >= confidence:
|
|
226
|
+
return 'AR'
|
|
227
|
+
return 'uncertain'
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# -- the patterns of inheritance, as graph features ------------------------------------------------------------------
|
|
231
|
+
PATTERN_FEATURES = (
|
|
232
|
+
'affected_share', 'affected_generations', 'vertical', 'skipped', 'male_to_male', 'father_daughters',
|
|
233
|
+
'father_sons', 'mother_sons', 'mother_daughters', 'male_share', 'sibship_share', 'consanguineous',
|
|
234
|
+
'x_kinship_ratio', 'log_affected', 'generations')
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _rate(hits: int, total: int) -> float:
|
|
238
|
+
"""A share, pulled towards a half when there are few to go on (with half a hit and half a miss added)"""
|
|
239
|
+
return (hits + 0.5) / (total + 1)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def pattern_features(pedigree: Pedigree) -> Dict[str, float]:
|
|
243
|
+
"""
|
|
244
|
+
Features of the pattern of affected people in the pedigree graph, each read off the DAG:
|
|
245
|
+
|
|
246
|
+
- affected_share: the share of the people with a known phenotype who are affected
|
|
247
|
+
- affected_generations: the share of the generations with someone affected in them
|
|
248
|
+
- vertical: of the affected people with parents in the pedigree, the share with an affected parent
|
|
249
|
+
- skipped: of the same people, the share whose parents are both unaffected
|
|
250
|
+
- male_to_male: whether an affected father has an affected son (1 or 0), which rules out the X
|
|
251
|
+
- father_daughters, father_sons: the shares of the daughters and the sons of affected fathers who are affected
|
|
252
|
+
- mother_sons, mother_daughters: the same for affected mothers
|
|
253
|
+
- male_share: the share of the affected people who are men
|
|
254
|
+
- sibship_share: of the brothers and sisters of affected people, the share affected
|
|
255
|
+
- consanguineous: whether an affected person is the child of related parents (1 or 0)
|
|
256
|
+
- x_kinship_ratio: over the pairs of affected relatives, their kinship on the X over their kinship on an
|
|
257
|
+
autosome, which is above 1 for men related through women and 0 for a father and son
|
|
258
|
+
- log_affected: the log of 1 + the number of affected people, and generations: the number of generations
|
|
259
|
+
|
|
260
|
+
The shares are pulled towards a half when there are few people to count (see _rate), so that every family has a
|
|
261
|
+
value for each.
|
|
262
|
+
"""
|
|
263
|
+
people = [p for p in pedigree.people if not p.placeholder]
|
|
264
|
+
known = [p for p in people if p.phenotype in (1, 2)]
|
|
265
|
+
affected = [p for p in people if p.affected]
|
|
266
|
+
generations = pedigree.generations()
|
|
267
|
+
gens_all = {generations[p.id] for p in people}
|
|
268
|
+
with_parents = [p for p in affected if p.father is not None]
|
|
269
|
+
vertical = sum(1 for p in with_parents if pedigree[p.father].affected or pedigree[p.mother].affected)
|
|
270
|
+
skipped = sum(1 for p in with_parents if pedigree[p.father].unaffected and pedigree[p.mother].unaffected)
|
|
271
|
+
counts = {key: [0, 0] for key in ('fd', 'fs', 'ms', 'md')}
|
|
272
|
+
male_to_male = 0
|
|
273
|
+
for p in people:
|
|
274
|
+
if p.father is None or p.phenotype not in (1, 2):
|
|
275
|
+
continue
|
|
276
|
+
for parent, prefix in ((pedigree[p.father], 'f'), (pedigree[p.mother], 'm')):
|
|
277
|
+
if not parent.affected or p.sex not in (MALE, FEMALE):
|
|
278
|
+
continue
|
|
279
|
+
key = prefix + ('s' if p.sex == MALE else 'd')
|
|
280
|
+
counts[key][0] += p.affected
|
|
281
|
+
counts[key][1] += 1
|
|
282
|
+
if prefix == 'f' and p.sex == MALE and p.affected:
|
|
283
|
+
male_to_male += 1
|
|
284
|
+
sexed = [p for p in affected if p.sex in (MALE, FEMALE)]
|
|
285
|
+
sibs = both = 0
|
|
286
|
+
for p in affected:
|
|
287
|
+
if p.father is None:
|
|
288
|
+
continue
|
|
289
|
+
for q in set(pedigree.children(p.father)) | set(pedigree.children(p.mother)):
|
|
290
|
+
if q != p.id and pedigree[q].phenotype in (1, 2):
|
|
291
|
+
sibs += 1
|
|
292
|
+
both += pedigree[q].affected
|
|
293
|
+
ids, phi = kinship_matrix(pedigree)
|
|
294
|
+
_, phi_x = kinship_matrix(pedigree, x_linked=True)
|
|
295
|
+
position = {pid: k for k, pid in enumerate(ids)}
|
|
296
|
+
index = [position[p.id] for p in affected]
|
|
297
|
+
upper = np.triu_indices(len(index), 1)
|
|
298
|
+
autosomal = phi[np.ix_(index, index)][upper]
|
|
299
|
+
on_x = phi_x[np.ix_(index, index)][upper]
|
|
300
|
+
related = autosomal > 0
|
|
301
|
+
x_ratio = float(np.mean(on_x[related] / autosomal[related])) if related.any() else 1.0
|
|
302
|
+
inbred = any(p.father is not None and phi[position[p.father], position[p.mother]] > 0 for p in affected)
|
|
303
|
+
return {
|
|
304
|
+
'affected_share': _rate(len(affected), len(known)),
|
|
305
|
+
'affected_generations': len({generations[p.id] for p in affected}) / max(len(gens_all), 1),
|
|
306
|
+
'vertical': _rate(vertical, len(with_parents)),
|
|
307
|
+
'skipped': _rate(skipped, len(with_parents)),
|
|
308
|
+
'male_to_male': float(male_to_male > 0),
|
|
309
|
+
'father_daughters': _rate(*counts['fd']),
|
|
310
|
+
'father_sons': _rate(*counts['fs']),
|
|
311
|
+
'mother_sons': _rate(*counts['ms']),
|
|
312
|
+
'mother_daughters': _rate(*counts['md']),
|
|
313
|
+
'male_share': _rate(sum(1 for p in sexed if p.sex == MALE), len(sexed)),
|
|
314
|
+
'sibship_share': _rate(both, sibs),
|
|
315
|
+
'consanguineous': float(inbred),
|
|
316
|
+
'x_kinship_ratio': x_ratio,
|
|
317
|
+
'log_affected': float(np.log1p(len(affected))),
|
|
318
|
+
'generations': float(len(gens_all)),
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@dataclass
|
|
323
|
+
class PatternClassifier:
|
|
324
|
+
"""
|
|
325
|
+
A multinomial logistic regression of the mode of inheritance on the pattern features: P(mode) is proportional to
|
|
326
|
+
exp(weights[mode] . (1, standardised features)), the features standardised by mean and scale
|
|
327
|
+
"""
|
|
328
|
+
modes: Tuple[str, ...]
|
|
329
|
+
features: Tuple[str, ...]
|
|
330
|
+
mean: np.ndarray
|
|
331
|
+
scale: np.ndarray
|
|
332
|
+
weights: np.ndarray
|
|
333
|
+
|
|
334
|
+
def probabilities(self, values: Dict[str, float]) -> Dict[str, float]:
|
|
335
|
+
x = (np.array([values[f] for f in self.features]) - self.mean) / self.scale
|
|
336
|
+
z = self.weights @ np.r_[1.0, x]
|
|
337
|
+
p = np.exp(z - z.max())
|
|
338
|
+
p /= p.sum()
|
|
339
|
+
return {m: float(v) for m, v in zip(self.modes, p)}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def train_pattern_classifier(families: Iterable[Tuple[Pedigree, str]], l2: float = 1.0,
|
|
343
|
+
iterations: int = 50) -> PatternClassifier:
|
|
344
|
+
"""
|
|
345
|
+
Fits the pattern classifier to (pedigree, mode) pairs by maximum likelihood with an L2 penalty of l2 on the
|
|
346
|
+
weights of the standardised features (not the intercepts), by Newton's method
|
|
347
|
+
"""
|
|
348
|
+
rows, labels = [], []
|
|
349
|
+
for pedigree, mode in families:
|
|
350
|
+
values = pattern_features(pedigree)
|
|
351
|
+
rows.append([values[f] for f in PATTERN_FEATURES])
|
|
352
|
+
labels.append(mode)
|
|
353
|
+
modes = tuple(m for m in MODES if m in set(labels))
|
|
354
|
+
X = np.array(rows, dtype=float)
|
|
355
|
+
mean = X.mean(axis=0)
|
|
356
|
+
scale = X.std(axis=0)
|
|
357
|
+
scale[scale == 0] = 1.0
|
|
358
|
+
Z = np.c_[np.ones(len(X)), (X - mean) / scale]
|
|
359
|
+
Y = np.array([[label == m for m in modes] for label in labels], dtype=float)
|
|
360
|
+
K, D = len(modes), Z.shape[1]
|
|
361
|
+
W = np.zeros((K, D))
|
|
362
|
+
penalty = np.full(D, l2)
|
|
363
|
+
penalty[0] = 1e-6
|
|
364
|
+
|
|
365
|
+
def objective(W):
|
|
366
|
+
logits = Z @ W.T
|
|
367
|
+
logits -= logits.max(axis=1, keepdims=True)
|
|
368
|
+
log_p = logits - np.log(np.exp(logits).sum(axis=1, keepdims=True))
|
|
369
|
+
return -(Y * log_p).sum() + 0.5 * (penalty * W ** 2).sum(), np.exp(log_p)
|
|
370
|
+
|
|
371
|
+
value, P = objective(W)
|
|
372
|
+
for _ in range(iterations):
|
|
373
|
+
gradient = ((P - Y).T @ Z) + penalty * W
|
|
374
|
+
H = np.zeros((K * D, K * D))
|
|
375
|
+
for a in range(K):
|
|
376
|
+
for b in range(K):
|
|
377
|
+
w = P[:, a] * ((a == b) - P[:, b])
|
|
378
|
+
H[a * D:(a + 1) * D, b * D:(b + 1) * D] = (Z * w[:, None]).T @ Z
|
|
379
|
+
H += np.diag(np.tile(penalty, K))
|
|
380
|
+
step = np.linalg.solve(H, gradient.ravel()).reshape(K, D)
|
|
381
|
+
t, improved = 1.0, False
|
|
382
|
+
while t > 1e-8:
|
|
383
|
+
new_value, new_P = objective(W - t * step)
|
|
384
|
+
if new_value <= value:
|
|
385
|
+
improved = True
|
|
386
|
+
break
|
|
387
|
+
t /= 2
|
|
388
|
+
if not improved:
|
|
389
|
+
break
|
|
390
|
+
W, change = W - t * step, value - new_value
|
|
391
|
+
value, P = new_value, new_P
|
|
392
|
+
if change < 1e-10 * max(1.0, abs(value)):
|
|
393
|
+
break
|
|
394
|
+
return PatternClassifier(modes, PATTERN_FEATURES, mean, scale, W)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@dataclass
|
|
398
|
+
class PatternResult:
|
|
399
|
+
"""The pattern classifier's answer: each mode's probability, most probable first, the call and the features"""
|
|
400
|
+
family: str
|
|
401
|
+
probabilities: Dict[str, float]
|
|
402
|
+
call: str
|
|
403
|
+
threshold: float
|
|
404
|
+
features: Dict[str, float] = field(default_factory=dict)
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def dominant(self) -> float:
|
|
408
|
+
return self.probabilities.get('AD', 0) + self.probabilities.get('XLD', 0)
|
|
409
|
+
|
|
410
|
+
@property
|
|
411
|
+
def recessive(self) -> float:
|
|
412
|
+
return self.probabilities.get('AR', 0) + self.probabilities.get('XLR', 0)
|
|
413
|
+
|
|
414
|
+
@property
|
|
415
|
+
def x_linked(self) -> float:
|
|
416
|
+
return self.probabilities.get('XLR', 0) + self.probabilities.get('XLD', 0)
|
|
417
|
+
|
|
418
|
+
def __str__(self):
|
|
419
|
+
lines = [f'Family {self.family}, from the pattern of affected people: '
|
|
420
|
+
f'{self.call if self.call == "uncertain" else MODE_NAMES[self.call]}']
|
|
421
|
+
for mode, p in self.probabilities.items():
|
|
422
|
+
lines.append(f' {mode:4s} {p:6.3f} ({MODE_NAMES[mode]})')
|
|
423
|
+
lines.append(f' dominant {self.dominant:.3f}, recessive {self.recessive:.3f}')
|
|
424
|
+
lines.append(f' X-linked {self.x_linked:.3f}, autosomal {1 - self.x_linked:.3f}')
|
|
425
|
+
return '\n'.join(lines)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def classify_by_patterns(pedigree: Pedigree, classifier: Optional[PatternClassifier] = None,
|
|
429
|
+
threshold: float = 0.8) -> PatternResult:
|
|
430
|
+
"""
|
|
431
|
+
The probability of each mode of inheritance from the pattern features alone, by the pattern classifier: by
|
|
432
|
+
default the one trained on simulated families of two to five generations, with penetrances from 0.4 to 1 (see
|
|
433
|
+
benchmarks/train_classifiers.py), whose probabilities are less to be trusted for families far from those. The
|
|
434
|
+
call is the most probable mode if its probability reaches threshold, and 'uncertain' otherwise.
|
|
435
|
+
"""
|
|
436
|
+
if classifier is None:
|
|
437
|
+
from ._trained import pattern_classifier
|
|
438
|
+
classifier = pattern_classifier()
|
|
439
|
+
values = pattern_features(pedigree)
|
|
440
|
+
probabilities = dict(sorted(classifier.probabilities(values).items(), key=lambda item: -item[1]))
|
|
441
|
+
best, p = next(iter(probabilities.items()))
|
|
442
|
+
call = best if p >= threshold and pedigree.affected() else 'uncertain'
|
|
443
|
+
return PatternResult(pedigree.family, probabilities, call, threshold, values)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# -- the original PedNet's network score of a variant --------------------------------------------------------------
|
|
447
|
+
NETWORK_WEIGHTS = {'w_edge': 0.6, 'w_gen': 0.2, 'w_bet': 0.2}
|
|
448
|
+
EXTENDED_WEIGHTS = {'w_edge': 0.6, 'w_gen': 0.1, 'w_bet': 0.1, 'w_found': 0.1, 'w_depth': 0.1}
|
|
449
|
+
_BOTH_PARENTS = {(0, 0): {0}, (1, 0): {0, 1}, (1, 1): {0, 1, 2}, (2, 1): {1, 2}, (2, 0): {1}, (2, 2): {2}}
|
|
450
|
+
_ONE_PARENT = {0: {0, 1}, 1: {0, 1, 2}, 2: {1, 2}}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def network_parts(pedigree: Pedigree, genotypes: Dict[str, Optional[int]]) -> Dict[str, float]:
|
|
454
|
+
"""
|
|
455
|
+
The parts of the original network score of a variant, from its genotypes (copies of the variant, for the people
|
|
456
|
+
genotyped):
|
|
457
|
+
|
|
458
|
+
- edge_consistency: the share of the parent-child edges between genotyped people whose genotypes Mendel's laws
|
|
459
|
+
allow (a child with both parents genotyped counts twice)
|
|
460
|
+
- generation_continuity: of the generations with someone genotyped, the share with a carrier
|
|
461
|
+
- carrier_betweenness: the average betweenness (not normalised) of the unaffected heterozygous carriers in the
|
|
462
|
+
graph of them and the affected people
|
|
463
|
+
- founder_influence: the average founder influence of the founders who carry it (see graph.founder_influence)
|
|
464
|
+
- alt_depth: the longest of the shortest paths from a founder who carries it down to another carrier, over the
|
|
465
|
+
length of the longest path in the pedigree
|
|
466
|
+
"""
|
|
467
|
+
from .graph import founder_influence
|
|
468
|
+
graph = metric_graph(pedigree)
|
|
469
|
+
gt = {pid: int(g) for pid, g in genotypes.items() if g is not None and pid in graph}
|
|
470
|
+
good = total = 0
|
|
471
|
+
for child in graph:
|
|
472
|
+
if child not in gt:
|
|
473
|
+
continue
|
|
474
|
+
typed = [p for p in graph.predecessors(child) if p in gt]
|
|
475
|
+
if len(typed) == 1:
|
|
476
|
+
good += gt[child] in _ONE_PARENT.get(gt[typed[0]], set())
|
|
477
|
+
total += 1
|
|
478
|
+
elif len(typed) == 2:
|
|
479
|
+
pair = (gt[typed[0]], gt[typed[1]])
|
|
480
|
+
pair = pair if pair in _BOTH_PARENTS else pair[::-1]
|
|
481
|
+
good += 2 * (gt[child] in _BOTH_PARENTS.get(pair, set()))
|
|
482
|
+
total += 2
|
|
483
|
+
generations = graph_generations(graph) if len(graph) else {}
|
|
484
|
+
typed_gens = {generations[n] for n in gt}
|
|
485
|
+
carrier_gens = {generations[n] for n in gt if gt[n] > 0}
|
|
486
|
+
affected = _affected(graph)
|
|
487
|
+
carriers = [n for n in _unaffected(graph) if gt.get(n) == 1]
|
|
488
|
+
betweenness = nx.betweenness_centrality(graph.subgraph(affected + carriers), normalized=False)
|
|
489
|
+
founders = [n for n in graph if graph.in_degree(n) == 0 and gt.get(n, 0) > 0]
|
|
490
|
+
influence = founder_influence(pedigree)
|
|
491
|
+
depth = nx.dag_longest_path_length(graph) if len(graph) else 0
|
|
492
|
+
reach = 0
|
|
493
|
+
for f in founders:
|
|
494
|
+
lengths = nx.single_source_shortest_path_length(graph, f)
|
|
495
|
+
reach = max([reach] + [lengths[n] for n in gt if gt[n] > 0 and n in lengths])
|
|
496
|
+
return {
|
|
497
|
+
'edge_consistency': good / total if total else 0.0,
|
|
498
|
+
'generation_continuity': len(carrier_gens) / len(typed_gens) if typed_gens else 0.0,
|
|
499
|
+
'carrier_betweenness': sum(betweenness[n] for n in carriers) / len(carriers) if carriers else 0.0,
|
|
500
|
+
'founder_influence': float(np.mean([influence.get(f, 0.0) for f in founders])) if founders else 0.0,
|
|
501
|
+
'alt_depth': reach / depth if depth else 0.0,
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def segregation_network_score(pedigree: Pedigree, genotypes: Dict[str, Optional[int]], mode: str = 'AD',
|
|
506
|
+
extended: bool = False, weights: Optional[Dict[str, float]] = None) -> float:
|
|
507
|
+
"""
|
|
508
|
+
The original PedNet's network score of a variant, between 0 and 1: a weighted sum of its edge consistency,
|
|
509
|
+
generation continuity and carrier betweenness (taken away from 1 except for AR), with founder influence and
|
|
510
|
+
depth too if extended. genotypes maps people to their copies of the variant.
|
|
511
|
+
"""
|
|
512
|
+
parts = network_parts(pedigree, genotypes)
|
|
513
|
+
weights = dict(weights or (EXTENDED_WEIGHTS if extended else NETWORK_WEIGHTS))
|
|
514
|
+
if extended and len(weights) < 5:
|
|
515
|
+
weights = dict(EXTENDED_WEIGHTS)
|
|
516
|
+
betweenness = parts['carrier_betweenness'] if mode == 'AR' else 1 - parts['carrier_betweenness']
|
|
517
|
+
score = (weights['w_edge'] * parts['edge_consistency']
|
|
518
|
+
+ weights['w_gen'] * min(max(parts['generation_continuity'], 0), 1)
|
|
519
|
+
+ weights['w_bet'] * min(max(betweenness, 0), 1))
|
|
520
|
+
if extended:
|
|
521
|
+
score += weights['w_found'] * parts['founder_influence'] + weights['w_depth'] * parts['alt_depth']
|
|
522
|
+
return float(score)
|