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