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/segregation.py ADDED
@@ -0,0 +1,751 @@
1
+ """How strongly each candidate variant co-segregates with the condition.
2
+
3
+ For each variant, PedNet compares two explanations of the family's phenotypes:
4
+
5
+ - the variant causes the condition: the genotypes at the disease locus are the variant's genotypes, so the people
6
+ who were sequenced fix part of the picture, and the genotypes of the rest are summed over
7
+ - it doesn't: the condition comes from some other, unseen locus, which the proband carries (the family having come
8
+ to notice through the proband), and whose genotypes in everyone else are summed over
9
+
10
+ The ratio of the two probabilities is the variant's co-segregation likelihood ratio (a Bayes factor). It is 2 to
11
+ the power of the number of informative meioses for a variant that follows the condition exactly with full
12
+ penetrance, the measure used for the co-segregation criteria of variant classification. Above 1, the variant
13
+ follows the condition through the family better than an unseen locus would be expected to; below 1, worse. Both
14
+ explanations average over the same penetrances, and over the modes of inheritance that suit the variant's
15
+ chromosome (autosomal dominant and recessive for an autosomal variant, X-linked recessive and dominant for one on
16
+ the X), each weighted by how well it explains the phenotypes.
17
+
18
+ Candidates are ranked by their odds against an unseen locus: the Bayes factor times the probability that the mode
19
+ is one that suits the candidate's chromosome, so that an X-linked variant ranks low in a family whose condition is
20
+ clearly autosomal, and the other way round.
21
+
22
+ Pairs of variants in the same gene are scored in the same way as compound heterozygotes, under the recessive modes
23
+ (see compound), and rank alongside the single variants.
24
+
25
+ The Bayes factor is the full-likelihood Bayes factor for co-segregation of Thompson et al. (2003), as segregatr
26
+ computes it, except that PedNet conditions on the proband having the disease genotype rather than on the proband's
27
+ genotype call: the same condition for every variant, so that their Bayes factors can be compared. Its strength as
28
+ evidence for the PP1 criterion of the ACMG/AMP guidelines (or against, BS4) is given by the Bayes factor thresholds
29
+ of Tavtigian et al. (2018), as Belman et al. (2020) apply them to co-segregation: 2.08 for supporting evidence, 4.33
30
+ moderate and 18.7 strong, and their reciprocals against.
31
+ """
32
+ import warnings
33
+ from collections import Counter
34
+ from dataclasses import dataclass, field
35
+ from typing import Dict, Iterable, List, Optional, Sequence, Tuple
36
+
37
+ import numpy as np
38
+
39
+ from .engine import Network
40
+ from .inheritance import (DEFAULT_PENETRANCE, DEFAULT_PHENOCOPY, LN10, _logsumexp, _modes, _prior, _weights,
41
+ mode_log_likelihoods)
42
+ from .models import MODES, X_LINKED, Model, call_codes, founder_prior, genotype_table, phenotype_factor
43
+ from .pedigree import MALE, Pedigree
44
+ from .compound import PAIR_MODES, PairLocus, has_disease_genotype, pair_log_likelihoods
45
+
46
+ DEFAULT_VARIANT_FREQUENCY = 0.001
47
+ # Rarer variants are treated as having this frequency. Below it, the frequency hardly changes how the variant can
48
+ # pass through the family, and a variant seen in the family is there, however rare it is in the population.
49
+ MIN_VARIANT_FREQUENCY = 0.001
50
+ DEFAULT_ERROR_RATE = 1e-4
51
+ _X_NAMES = {'x', 'chrx', '23', 'chr23'}
52
+ # PLINK numbers the Y 24, the pseudoautosomal regions 25 (inherited like an autosome) and the mitochondria 26
53
+ _Y_NAMES = {'y', 'chry', '24', 'chr24'}
54
+ _MT_NAMES = {'mt', 'm', 'chrm', 'chrmt', '26', 'chr26'}
55
+
56
+ # The strengths of evidence for co-segregation (PP1) or against it (BS4), by the log10 Bayes factor: the thresholds of
57
+ # Tavtigian et al. (2018) for the ACMG/AMP criteria, 18.7 strong, 4.33 moderate and 2.08 supporting
58
+ EVIDENCE = ((np.log10(18.7), 'Strong'), (np.log10(4.33), 'Moderate'), (np.log10(2.08), 'Supporting'))
59
+
60
+
61
+ def evidence_strength(log10_bf: Optional[float]) -> str:
62
+ """PP1_Strong, PP1_Moderate or PP1_Supporting for co-segregation, BS4_ likewise against it, or '' for neither"""
63
+ if log10_bf is None:
64
+ return ''
65
+ for threshold, strength in EVIDENCE:
66
+ if log10_bf >= threshold - 1e-12:
67
+ return f'PP1_{strength}'
68
+ if log10_bf <= -threshold + 1e-12:
69
+ return f'BS4_{strength}'
70
+ return ''
71
+
72
+
73
+ @dataclass
74
+ class Variant:
75
+ """
76
+ A candidate variant: its genotypes, as the number of copies of the alternate allele each person carries (0, 1 or 2;
77
+ None, or no entry, if not genotyped), with where it is if known, its allele frequency in the population, and
78
+ the gene it is in (which pairs variants up as compound heterozygotes).
79
+ """
80
+ id: str
81
+ genotypes: Dict[str, Optional[int]]
82
+ chrom: Optional[str] = None
83
+ pos: Optional[int] = None
84
+ ref: Optional[str] = None
85
+ alt: Optional[str] = None
86
+ allele_frequency: Optional[float] = None
87
+ gene: Optional[str] = None
88
+
89
+ @property
90
+ def x_linked(self) -> Optional[bool]:
91
+ """True on the X chromosome, False on another, None if the chromosome isn't known"""
92
+ if self.chrom is None:
93
+ return None
94
+ return str(self.chrom).strip().lower() in _X_NAMES
95
+
96
+ @property
97
+ def unmodelled(self) -> bool:
98
+ """Whether the variant is on the Y chromosome or in the mitochondria, whose inheritance PedNet doesn't model"""
99
+ return self.chrom is not None and str(self.chrom).strip().lower() in _Y_NAMES | _MT_NAMES
100
+
101
+
102
+ @dataclass
103
+ class VariantScore:
104
+ """
105
+ One candidate's evidence: a variant, or with a partner, a pair of variants in one gene as a compound
106
+ heterozygote.
107
+
108
+ log10_lr is the log10 likelihood ratio for co-segregation under each mode that suits the candidate's chromosome
109
+ on its own (only AR, or XLR, for a pair), and log_likelihood the natural log of the probability of the
110
+ phenotypes, given the genotypes, with the candidate as the cause, under each of those modes. log10_bf, the log10
111
+ co-segregation Bayes factor, combines the modes, each weighted by how probable the family's phenotypes make it:
112
+ it is near 0 for a variant whose genotypes say nothing, and evidence is its strength as evidence for
113
+ co-segregation (PP1) or against it (BS4). evidence is left blank, and evidence_withheld says why, for a variant
114
+ that may be new in an affected person and is otherwise consistent with the family (for PS2 or PM6 to judge,
115
+ not PP1 or BS4), and for one that is part of a pair which is evidence for co-segregation (PP1, a log10 BF of
116
+ 0.318 or more) and better evidence than the variant alone. best_mode is the most probable mode of inheritance if
117
+ the candidate is the cause. log10_bf is None, and note says why, for a candidate that can't be scored.
118
+ impossible_modes lists the modes under which its genotypes can't have been passed down at all, which happens
119
+ only with an error rate of 0: they are left out, and if no mode is left, the candidate isn't scored.
120
+
121
+ mode_probability is the probability, from the phenotypes, that the mode is one that suits the candidate
122
+ (autosomal or X-linked, or recessive for a pair), and log10_odds = log10_bf + log10(mode_probability): the
123
+ log10 odds that this candidate, rather than some other locus, causes the condition, with even odds beforehand.
124
+ The candidates are ranked by it, so that an X-linked variant in a family whose condition is clearly autosomal
125
+ doesn't rank highly. rank is 1 for the candidate with the most evidence, and tied candidates share the average
126
+ of their ranks.
127
+
128
+ The rest describes the genotypes. A carrier has the variant (for a pair, the genotype of a compound heterozygote:
129
+ both variants, or two copies of either). concordance is the share of the genotyped people with a known phenotype
130
+ whose genotype fits it under the best mode, as a plain check. mendelian_errors counts the genotyped children whose
131
+ genotype their genotyped parents can't have given them, which suggests calling errors: the Bayes factor allows
132
+ for errors, but such a variant is worth checking. de_novo lists the genotyped children who carry the variant
133
+ though both their parents were genotyped without it (on the X, a son whose mother was): the Bayes factor counts
134
+ such a variant against itself, since PedNet doesn't model new mutations, and it needs judging on its own.
135
+ """
136
+ variant: Variant
137
+ log10_bf: Optional[float]
138
+ log10_lr: Dict[str, float] = field(default_factory=dict)
139
+ log_likelihood: Dict[str, float] = field(default_factory=dict)
140
+ rank: Optional[float] = None
141
+ log10_odds: Optional[float] = None
142
+ mode_probability: Optional[float] = None
143
+ genotyped: int = 0
144
+ affected_carriers: int = 0
145
+ affected_noncarriers: int = 0
146
+ unaffected_carriers: int = 0
147
+ unaffected_noncarriers: int = 0
148
+ best_mode: Optional[str] = None
149
+ concordance: Optional[float] = None
150
+ mendelian_errors: int = 0
151
+ note: str = ''
152
+ partner: Optional[Variant] = None
153
+ de_novo: List[str] = field(default_factory=list)
154
+ evidence_withheld: str = ''
155
+ impossible_modes: List[str] = field(default_factory=list)
156
+
157
+ @property
158
+ def id(self) -> str:
159
+ return self.variant.id if self.partner is None else f'{self.variant.id} + {self.partner.id}'
160
+
161
+ @property
162
+ def compound(self) -> bool:
163
+ return self.partner is not None
164
+
165
+ @property
166
+ def evidence(self) -> str:
167
+ """The strength of the evidence, or '' when there is none or evidence_withheld says why it isn't given"""
168
+ return '' if self.evidence_withheld else evidence_strength(self.log10_bf)
169
+
170
+ @property
171
+ def notes(self) -> str:
172
+ """The note, with the Mendelian errors, possible new mutations and why any evidence is withheld"""
173
+ parts = [self.note] if self.note else []
174
+ if self.mendelian_errors:
175
+ parts.append(f'{self.mendelian_errors} Mendelian error{"s" if self.mendelian_errors > 1 else ""}')
176
+ if self.de_novo:
177
+ parts.append(f'possibly de novo in {", ".join(self.de_novo)}')
178
+ if self.evidence_withheld:
179
+ parts.append(self.evidence_withheld)
180
+ return '; '.join(parts)
181
+
182
+ def row(self) -> dict:
183
+ v, w = self.variant, self.partner
184
+
185
+ def both(name):
186
+ if w is None:
187
+ return getattr(v, name)
188
+ return ','.join('' if x is None else str(x) for x in (getattr(v, name), getattr(w, name)))
189
+
190
+ row = {'rank': self.rank, 'candidate': self.id, 'kind': 'compound heterozygote' if w else 'variant',
191
+ 'gene': v.gene, 'chrom': v.chrom, 'pos': both('pos'), 'ref': both('ref'), 'alt': both('alt'),
192
+ 'allele_frequency': both('allele_frequency'), 'log10_bf': self.log10_bf, 'evidence': self.evidence,
193
+ 'log10_odds': self.log10_odds, 'mode_probability': self.mode_probability, 'best_mode': self.best_mode}
194
+ for mode in MODES:
195
+ row[f'log10_lr_{mode}'] = self.log10_lr.get(mode)
196
+ row.update(genotyped=self.genotyped, affected_carriers=self.affected_carriers,
197
+ affected_noncarriers=self.affected_noncarriers, unaffected_carriers=self.unaffected_carriers,
198
+ unaffected_noncarriers=self.unaffected_noncarriers, concordance=self.concordance,
199
+ mendelian_errors=self.mendelian_errors, de_novo=','.join(self.de_novo), note=self.note,
200
+ evidence_withheld=self.evidence_withheld)
201
+ return row
202
+
203
+
204
+ def _fits(mode: str, copies: int, male: Optional[bool], affected: bool) -> bool:
205
+ """Whether a genotype fits a phenotype under a mode with full penetrance and no phenocopies"""
206
+ if mode == 'AD':
207
+ disease = copies >= 1
208
+ elif mode == 'AR':
209
+ disease = copies == 2
210
+ elif mode == 'XLD':
211
+ disease = copies >= 1
212
+ else:
213
+ disease = copies >= 1 if male else copies == 2
214
+ return disease == affected
215
+
216
+
217
+ def fair_ranks(values: Sequence[Optional[float]], decimals: int = 9) -> List[Optional[float]]:
218
+ """
219
+ Ranks, highest first, with tied values sharing the average of their ranks, and None for a missing value. Values
220
+ equal to the given number of decimal places are tied, so that likelihood ratios that are equal in theory but differ
221
+ in the last digits of the arithmetic share their rank.
222
+ """
223
+ rounded = [None if v is None or v != v else round(v, decimals) for v in values]
224
+ count = Counter(v for v in rounded if v is not None)
225
+ higher, above = {}, 0
226
+ for v in sorted(count, reverse=True):
227
+ higher[v] = above
228
+ above += count[v]
229
+ return [None if v is None else 1 + higher[v] + (count[v] - 1) / 2 for v in rounded]
230
+
231
+
232
+ def _frequency(v: Variant, default: float, lowest: float) -> float:
233
+ q = v.allele_frequency if v.allele_frequency is not None else default
234
+ return float(np.clip(q, max(lowest, 1e-8), 0.5))
235
+
236
+
237
+ def rank_variants(pedigree: Pedigree, variants: Iterable[Variant], modes: Sequence[str] = MODES,
238
+ penetrance=DEFAULT_PENETRANCE, penetrance_weights=None, phenocopy: float = DEFAULT_PHENOCOPY,
239
+ allele_frequency: Optional[Dict[str, float]] = None, carrier_penetrance: Optional[float] = None,
240
+ variant_frequency: float = DEFAULT_VARIANT_FREQUENCY, min_frequency: float = MIN_VARIANT_FREQUENCY,
241
+ error_rate: float = DEFAULT_ERROR_RATE, prior: Optional[Dict[str, float]] = None,
242
+ proband: Optional[str] = None, compound: bool = True, chunk: int = 2000) -> List[VariantScore]:
243
+ """
244
+ Scores candidate variants for co-segregation with the condition in one family, and ranks them, the strongest
245
+ evidence first.
246
+
247
+ variants: Variant objects (see read_vcf and read_genotype_table); people not in the pedigree are ignored
248
+ modes: the modes of inheritance to allow (by default all four); give one if the mode is known
249
+ penetrance, penetrance_weights, phenocopy, allele_frequency, carrier_penetrance, prior, proband: as for
250
+ infer_mode; allele_frequency is that of the unseen locus in the explanation without the variant, and proband
251
+ the person that locus is taken to be carried by (by default, each affected person in turn)
252
+ variant_frequency: the allele frequency to use for a variant that doesn't give its own
253
+ min_frequency: the lowest frequency used; rarer variants are treated as having this one. If it were far below
254
+ error_rate, a genotype call in one person would be put down to an error rather than to the variant
255
+ error_rate: the chance that a genotype call is one copy out (see models.genotype_factor)
256
+ compound: whether to score pairs of variants in one gene (see Variant.gene) as compound heterozygotes too, when a
257
+ recessive mode is allowed: the pairs that an affected person (the proband, if given) carries both of
258
+ """
259
+ modes = _modes(modes)
260
+ priors = _prior(modes, prior)
261
+ pens = (float(penetrance),) if np.isscalar(penetrance) else tuple(float(f) for f in penetrance)
262
+ with np.errstate(divide='ignore'):
263
+ log_w = np.log(_weights(pens, penetrance_weights))
264
+ allele_frequency = allele_frequency or {}
265
+ variants = list(variants)
266
+ networks: Dict[bool, Network] = {}
267
+ if proband is not None and proband not in pedigree:
268
+ raise ValueError(f'{proband} is not in family {pedigree.family}')
269
+ if proband is not None and not pedigree[proband].affected:
270
+ raise ValueError(f'The proband, {proband}, is not recorded as affected: the Bayes factors take the family to '
271
+ 'have come to notice through an affected person')
272
+ models = {m: Model(m, allele_frequency.get(m), pens[0], phenocopy, carrier_penetrance) for m in modes}
273
+
274
+ # the explanation without the variant: P(phenotypes | the proband carries the disease genotype, mode), averaged
275
+ # over the penetrances and the probands, as for the mode of inheritance
276
+ log_without = mode_log_likelihoods(pedigree, modes, pens, penetrance_weights, phenocopy, allele_frequency,
277
+ carrier_penetrance, [proband] if proband else None, networks)
278
+ log_priors = {m: float(np.log(priors[m])) for m in modes if priors[m] > 0}
279
+
280
+ scores = [VariantScore(v, None) for v in variants]
281
+ errors = [mendelian_errors(pedigree, v) for v in variants]
282
+ for k, v in enumerate(variants):
283
+ _describe(pedigree, v, scores[k])
284
+ scores[k].mendelian_errors = errors[k]
285
+ scores[k].de_novo = de_novo(pedigree, v)
286
+
287
+ # the explanation with the variant as the cause, for each mode that suits its chromosome
288
+ for x in (False, True):
289
+ chosen = [k for k, v in enumerate(variants)
290
+ if v.x_linked in (x, None) and not v.unmodelled and scores[k].genotyped]
291
+ suited = [m for m in modes if X_LINKED[m] == x and priors[m] > 0]
292
+ if not chosen or not suited:
293
+ continue
294
+ if x not in networks:
295
+ networks[x] = Network(pedigree, x)
296
+ for start in range(0, len(chosen), chunk):
297
+ batch = chosen[start:start + chunk]
298
+ _score_batch(pedigree, networks[x], [variants[k] for k in batch], [scores[k] for k in batch], suited,
299
+ models, pens, log_w, variant_frequency, min_frequency, error_rate, log_without)
300
+
301
+ # pairs of variants in one gene, as compound heterozygotes
302
+ pairs = []
303
+ if compound:
304
+ allowed = {x: [m for m in PAIR_MODES if m in modes and priors[m] > 0 and x in (X_LINKED[m], None)]
305
+ for x in (False, True, None)}
306
+ pairs = [(i, j) for i, j in candidate_pairs(pedigree, variants, proband) if allowed[variants[i].x_linked]]
307
+ pair_scores = []
308
+ for i, j in pairs:
309
+ score = VariantScore(variants[i], None, partner=variants[j], mendelian_errors=errors[i] + errors[j],
310
+ de_novo=sorted(set(scores[i].de_novo) | set(scores[j].de_novo)))
311
+ _describe_pair(pedigree, variants[i], variants[j], score)
312
+ pair_scores.append(score)
313
+ left_out = set()
314
+ for x in (False, True):
315
+ chosen = [k for k, (i, j) in enumerate(pairs) if variants[i].x_linked in (x, None)]
316
+ suited = [m for m in PAIR_MODES if m in modes and X_LINKED[m] == x and priors[m] > 0]
317
+ if not chosen or not suited:
318
+ continue
319
+ try:
320
+ net = Network(pedigree, x, PairLocus())
321
+ except ValueError as error:
322
+ warnings.warn(f'Pairs of variants in one gene are left out: {error} for a pair (single variants are '
323
+ 'still scored)')
324
+ left_out.update(chosen)
325
+ continue
326
+ for start in range(0, len(chosen), chunk):
327
+ batch = chosen[start:start + chunk]
328
+ both = [(variants[pairs[k][0]], variants[pairs[k][1]]) for k in batch]
329
+ frequencies = [(_frequency(a, variant_frequency, min_frequency), _frequency(b, variant_frequency, min_frequency))
330
+ for a, b in both]
331
+ logs = pair_log_likelihoods(pedigree, net, both, frequencies, suited, models, pens, log_w, error_rate)
332
+ for mode, values in logs.items():
333
+ for k, value in zip(batch, values):
334
+ _set_mode(pair_scores[k], mode, float(value), log_without[mode])
335
+ pair_scores = [p for k, p in enumerate(pair_scores) if k not in left_out]
336
+
337
+ groups: Dict[Tuple[str, ...], List[VariantScore]] = {}
338
+ for s in scores + pair_scores:
339
+ if s.variant.unmodelled:
340
+ s.note = 'Y-linked and mitochondrial inheritance aren\'t modelled'
341
+ elif not s.genotyped:
342
+ s.note = 'no one in the family was genotyped for it'
343
+ elif s.impossible_modes and not s.log10_lr:
344
+ s.note = _IMPOSSIBLE
345
+ elif not s.log10_lr:
346
+ s.note = 'none of the modes allowed suits its chromosome'
347
+ else:
348
+ if s.impossible_modes:
349
+ # a variant of unknown chromosome whose genotypes rule out the X, say: the other modes remain
350
+ s.note = (f'{" and ".join(s.impossible_modes)} {"is" if len(s.impossible_modes) == 1 else "are"} '
351
+ 'left out, since its genotypes can\'t have been passed down that way without a genotyping '
352
+ 'error, and the error rate is 0')
353
+ groups.setdefault(tuple(s.log10_lr), []).append(s)
354
+ for suited, group in groups.items():
355
+ _combine_modes(group, suited, log_priors, log_without)
356
+ for s in group:
357
+ if s.best_mode is None:
358
+ continue
359
+ if s.partner is None:
360
+ s.concordance = _concordance(pedigree, s.variant, s.best_mode)
361
+ else:
362
+ s.concordance = _pair_concordance(pedigree, s.variant, s.partner)
363
+ _withhold_evidence(pedigree, scores, pair_scores)
364
+ scores = scores + pair_scores
365
+ for s, r in zip(scores, fair_ranks([s.log10_odds for s in scores])):
366
+ s.rank = r
367
+ scores.sort(key=lambda s: (s.rank is None, s.rank if s.rank is not None else 0))
368
+ return scores
369
+
370
+
371
+ def _set_mode(score: 'VariantScore', mode: str, log_with: float, log_without: float):
372
+ """
373
+ Records the log likelihood of the phenotypes under a mode with the candidate as the cause, and the ratio. A
374
+ likelihood of NaN comes from genotypes that have no probability at all under the mode (0 / 0), which only an error
375
+ rate of 0 and a Mendelian error can give: the mode is then left out for the candidate (see impossible_modes).
376
+ """
377
+ if log_with != log_with:
378
+ if mode not in score.impossible_modes:
379
+ score.impossible_modes.append(mode)
380
+ return
381
+ score.log_likelihood[mode] = log_with
382
+ with np.errstate(invalid='ignore'):
383
+ ratio = (log_with - log_without) / LN10
384
+ score.log10_lr[mode] = float(ratio) if ratio == ratio else float('-inf')
385
+
386
+
387
+ def _combine_modes(group: List['VariantScore'], suited: Sequence[str], log_priors: Dict[str, float],
388
+ log_without: Dict[str, float]):
389
+ """
390
+ The Bayes factor and odds of candidates whose likelihoods are for the modes suited: the probability of the
391
+ phenotypes with the candidate as the cause, averaged over those modes by their priors, over the same without it.
392
+ A mode that can't explain the phenotypes without the candidate adds nothing to the second, rather than making the
393
+ likelihood ratio infinite.
394
+ """
395
+ suited = [m for m in suited if m in log_priors]
396
+ log_all = _logsumexp([log_priors[m] + log_without[m] for m in log_priors])
397
+ log_suited = _logsumexp([log_priors[m] + log_without[m] for m in suited]) if suited else -np.inf
398
+ if not np.isfinite(log_suited) or not np.isfinite(log_all):
399
+ for s in group:
400
+ s.note = s.note or 'the phenotypes rule out every mode that suits its chromosome'
401
+ return
402
+ priors = np.array([log_priors[m] for m in suited])
403
+ for s in group:
404
+ with_modes = priors + np.array([s.log_likelihood[m] for m in suited])
405
+ value = _logsumexp(with_modes)
406
+ if not np.isfinite(value):
407
+ s.note = s.note or 'its genotypes can\'t give these phenotypes under any mode that suits it'
408
+ s.log10_bf = s.log10_odds = float('-inf')
409
+ s.mode_probability = float(np.exp(log_suited - log_all))
410
+ s.best_mode = suited[int(np.argmax(with_modes))]
411
+ continue
412
+ s.log10_bf = float((value - log_suited) / LN10)
413
+ s.mode_probability = float(np.exp(log_suited - log_all))
414
+ s.log10_odds = float((value - log_all) / LN10)
415
+ s.best_mode = suited[int(np.argmax(with_modes))]
416
+
417
+
418
+ def _withhold_evidence(pedigree: Pedigree, scores: List['VariantScore'], pair_scores: List['VariantScore']):
419
+ """Leaves out the strength of evidence where it would mislead (see VariantScore)"""
420
+ best_pair: Dict[int, 'VariantScore'] = {}
421
+ for p in pair_scores:
422
+ if p.log10_bf is None:
423
+ continue
424
+ for member in (p.variant, p.partner):
425
+ known = best_pair.get(id(member))
426
+ if known is None or p.log10_bf > known.log10_bf:
427
+ best_pair[id(member)] = p
428
+ for s in scores:
429
+ if s.log10_bf is None:
430
+ continue
431
+ pair = best_pair.get(id(s.variant))
432
+ if _new_in_affected(pedigree, s):
433
+ s.evidence_withheld = _NEW
434
+ elif pair is not None and evidence_strength(pair.log10_bf).startswith('PP1') and pair.log10_bf > s.log10_bf:
435
+ # the pair is evidence for co-segregation, and better evidence than the variant alone
436
+ s.evidence_withheld = f'judge it with its pair, {pair.id} (log10 BF {pair.log10_bf:.2f})'
437
+ for p in pair_scores:
438
+ if p.log10_bf is not None and _new_in_affected(pedigree, p):
439
+ p.evidence_withheld = _NEW
440
+
441
+
442
+ _NEW = 'co-segregation can\'t judge a variant new in an affected person: see PS2 and PM6 instead'
443
+ _IMPOSSIBLE = ('its genotypes can\'t have been passed down without a genotyping error, and the error rate is 0, so it '
444
+ 'isn\'t scored')
445
+
446
+
447
+ def _new_in_affected(pedigree: Pedigree, s: 'VariantScore') -> bool:
448
+ """
449
+ Whether the candidate may be new in an affected person, and its genotypes are otherwise consistent: no other
450
+ Mendelian errors, and no affected person genotyped without it. Then the Bayes factor, which counts the new
451
+ mutation against it, says nothing about whether it causes the condition. An affected relative without it is
452
+ evidence against it whatever its origin.
453
+ """
454
+ return (any(pedigree[pid].affected for pid in s.de_novo) and s.mendelian_errors <= len(s.de_novo)
455
+ and s.affected_noncarriers == 0)
456
+
457
+
458
+ def candidate_pairs(pedigree: Pedigree, variants: Sequence[Variant],
459
+ proband: Optional[str] = None) -> List[Tuple[int, int]]:
460
+ """
461
+ The pairs of variants in one gene, as pairs of positions in variants, that an affected person (the proband, if
462
+ given) carries both of; on the X, an affected person who isn't a man, since a man has only one copy
463
+ """
464
+ who = [proband] if proband else pedigree.affected()
465
+ by_gene: Dict[str, List[int]] = {}
466
+ for k, v in enumerate(variants):
467
+ if v.gene:
468
+ by_gene.setdefault(v.gene, []).append(k)
469
+ pairs = []
470
+ for ks in by_gene.values():
471
+ for a in range(len(ks)):
472
+ for b in range(a + 1, len(ks)):
473
+ v, w = variants[ks[a]], variants[ks[b]]
474
+ if v.x_linked != w.x_linked or v.unmodelled or w.unmodelled:
475
+ continue
476
+ for pid in who:
477
+ if pid not in pedigree or (v.x_linked and pedigree[pid].sex == MALE):
478
+ continue
479
+ if (v.genotypes.get(pid) or 0) >= 1 and (w.genotypes.get(pid) or 0) >= 1:
480
+ pairs.append((ks[a], ks[b]))
481
+ break
482
+ return pairs
483
+
484
+
485
+ def _describe(pedigree: Pedigree, v: Variant, s: VariantScore):
486
+ for pid, copies in v.genotypes.items():
487
+ if copies is None or pid not in pedigree:
488
+ continue
489
+ person = pedigree[pid]
490
+ s.genotyped += 1
491
+ carrier = copies >= 1
492
+ if person.affected:
493
+ if carrier:
494
+ s.affected_carriers += 1
495
+ else:
496
+ s.affected_noncarriers += 1
497
+ elif person.unaffected:
498
+ if carrier:
499
+ s.unaffected_carriers += 1
500
+ else:
501
+ s.unaffected_noncarriers += 1
502
+
503
+
504
+ def _describe_pair(pedigree: Pedigree, v: Variant, w: Variant, s: VariantScore):
505
+ for pid in pedigree.ids:
506
+ a, b = v.genotypes.get(pid), w.genotypes.get(pid)
507
+ if a is None and b is None:
508
+ continue
509
+ s.genotyped += 1
510
+ if a is None or b is None:
511
+ continue
512
+ person = pedigree[pid]
513
+ carrier = has_disease_genotype(a, b, v.x_linked and person.sex == MALE)
514
+ if person.affected:
515
+ if carrier:
516
+ s.affected_carriers += 1
517
+ else:
518
+ s.affected_noncarriers += 1
519
+ elif person.unaffected:
520
+ if carrier:
521
+ s.unaffected_carriers += 1
522
+ else:
523
+ s.unaffected_noncarriers += 1
524
+
525
+
526
+ def _pair_concordance(pedigree: Pedigree, v: Variant, w: Variant) -> Optional[float]:
527
+ fits = total = 0
528
+ for pid in pedigree.ids:
529
+ a, b = v.genotypes.get(pid), w.genotypes.get(pid)
530
+ person = pedigree[pid]
531
+ if a is None or b is None or person.phenotype not in (1, 2):
532
+ continue
533
+ total += 1
534
+ fits += has_disease_genotype(a, b, v.x_linked and person.sex == MALE) == person.affected
535
+ return fits / total if total else None
536
+
537
+
538
+ def de_novo(pedigree: Pedigree, v: Variant) -> List[str]:
539
+ """
540
+ The genotyped children who carry the variant though both their parents were genotyped without it; on the X, a
541
+ son who carries it though his mother was genotyped without it
542
+ """
543
+ found = []
544
+ x = v.x_linked
545
+ if v.unmodelled:
546
+ return found
547
+ for p in pedigree.people:
548
+ child = v.genotypes.get(p.id)
549
+ if not child or p.father is None:
550
+ continue
551
+ father, mother = v.genotypes.get(p.father), v.genotypes.get(p.mother)
552
+ if x and p.sex == MALE:
553
+ if mother == 0:
554
+ found.append(p.id)
555
+ elif father == 0 and mother == 0:
556
+ found.append(p.id)
557
+ return found
558
+
559
+
560
+ def mendelian_errors(pedigree: Pedigree, v: Variant) -> int:
561
+ """The genotyped children whose genotype can't have come from their genotyped parents"""
562
+ if v.unmodelled:
563
+ return 0
564
+ x = v.x_linked
565
+ errors = 0
566
+ for p in pedigree.people:
567
+ child = v.genotypes.get(p.id)
568
+ if child is None or p.father is None:
569
+ continue
570
+ father, mother = v.genotypes.get(p.father), v.genotypes.get(p.mother)
571
+ if x:
572
+ # a son has only his mother's X; a daughter has her father's (all of it) and one of her mother's
573
+ if p.sex == 1:
574
+ if mother is not None and ((mother == 0 and child >= 1) or (mother == 2 and child == 0)):
575
+ errors += 1
576
+ continue
577
+ if p.sex != 2:
578
+ continue
579
+ low = (1 if father is not None and father >= 1 else 0) + (1 if mother == 2 else 0)
580
+ high = (0 if father == 0 else 1) + (0 if mother == 0 else 1)
581
+ else:
582
+ low = (1 if father == 2 else 0) + (1 if mother == 2 else 0)
583
+ high = (0 if father == 0 else 1) + (0 if mother == 0 else 1)
584
+ if not low <= child <= high:
585
+ errors += 1
586
+ return errors
587
+
588
+
589
+ def _concordance(pedigree: Pedigree, v: Variant, mode: str) -> Optional[float]:
590
+ fits = total = 0
591
+ for pid, copies in v.genotypes.items():
592
+ if copies is None or pid not in pedigree:
593
+ continue
594
+ person = pedigree[pid]
595
+ if person.phenotype not in (1, 2):
596
+ continue
597
+ male = person.sex == 1 if person.sex in (1, 2) else None
598
+ total += 1
599
+ fits += _fits(mode, copies, male, person.affected)
600
+ return fits / total if total else None
601
+
602
+
603
+ def _score_batch(pedigree, net, variants, scores, modes, models, pens, log_w, variant_frequency, min_frequency,
604
+ error_rate, log_without):
605
+ V, F = len(variants), len(pens)
606
+ q = np.array([_frequency(v, variant_frequency, min_frequency) for v in variants])
607
+ evidence = []
608
+ for i, (kind, person) in enumerate(zip(net.kinds, pedigree.people)):
609
+ e = genotype_table(kind, error_rate)[call_codes(variants, person.id)]
610
+ if net.parents[i] is None:
611
+ e = e * founder_prior(kind, q)
612
+ evidence.append(e)
613
+ # P(genotypes), which doesn't depend on the mode except through the chromosome
614
+ log_genotypes = net.log_likelihood(evidence)
615
+ for mode in modes:
616
+ model = models[mode]
617
+ unary = []
618
+ for i, (kind, person) in enumerate(zip(net.kinds, pedigree.people)):
619
+ phen = np.stack([phenotype_factor(model.with_(penetrance=f), kind, person.phenotype) for f in pens])
620
+ unary.append((phen[:, None, :] * evidence[i][None, :, :]).reshape(F * V, -1))
621
+ joint = net.log_likelihood(unary).reshape(F, V)
622
+ with np.errstate(invalid='ignore'):
623
+ # NaN where the genotypes have no probability at all (see _set_mode)
624
+ with_variant = _logsumexp(joint + log_w[:, None], axis=0) - log_genotypes
625
+ for s, value in zip(scores, with_variant):
626
+ _set_mode(s, mode, float(value), log_without[mode])
627
+
628
+
629
+ @dataclass
630
+ class CombinedScore:
631
+ """
632
+ A candidate's evidence from several families with the same condition, over the families in which an affected
633
+ person (the family's proband, if given) carries it, whose scores are in families. A family in which no affected
634
+ person carries it counts only towards the mode of inheritance, since the condition may have another cause there.
635
+
636
+ log10_bf is the log10 Bayes factor over those families together, with the mode of inheritance shared between
637
+ them. With one mode allowed it is the sum of the families' log10 Bayes factors. With several, each mode is
638
+ weighted by how well it explains all the families together, so the evidence can be more than the sum when the
639
+ families agree on the mode and less when they point to different modes, and each family's own log10_bf (in
640
+ families) is for the modes weighted by that family alone. A mode under which the candidate's genotypes are
641
+ impossible in one family (with an error rate of 0) is left out for all of them. mode_probability is the
642
+ probability, from all the families' phenotypes, that the mode is one that suits the candidate, and log10_odds is
643
+ log10_bf + log10(mode_probability), as for VariantScore. log10_bf is None, and note says why, when the candidate
644
+ can't be scored.
645
+ """
646
+ id: str
647
+ log10_bf: Optional[float]
648
+ families: Dict[str, VariantScore]
649
+ rank: Optional[float] = None
650
+ log10_odds: Optional[float] = None
651
+ mode_probability: Optional[float] = None
652
+ evidence_withheld: str = ''
653
+ note: str = ''
654
+
655
+ @property
656
+ def evidence(self) -> str:
657
+ if self.evidence_withheld or self.log10_bf != self.log10_bf:
658
+ return ''
659
+ return evidence_strength(self.log10_bf)
660
+
661
+ @property
662
+ def compound(self) -> bool:
663
+ return next(iter(self.families.values())).compound
664
+
665
+ def row(self) -> dict:
666
+ first = next(iter(self.families.values()))
667
+ row = {'rank': self.rank, 'candidate': self.id, 'kind': 'compound heterozygote' if self.compound else 'variant',
668
+ 'gene': first.variant.gene, 'log10_bf': self.log10_bf, 'evidence': self.evidence,
669
+ 'log10_odds': self.log10_odds, 'mode_probability': self.mode_probability,
670
+ 'families': ','.join(self.families)}
671
+ for name, s in self.families.items():
672
+ row[f'log10_bf_{name}'] = s.log10_bf
673
+ row.update(evidence_withheld=self.evidence_withheld, note=self.note)
674
+ return row
675
+
676
+
677
+ def rank_variants_across(pedigrees, variants: Iterable[Variant], probands: Optional[Dict[str, str]] = None,
678
+ **options) -> List[CombinedScore]:
679
+ """
680
+ Ranks candidates over several families with the same condition (see CombinedScore). The families are taken to
681
+ share one mode of inheritance: for each mode, the families' likelihoods are multiplied, and the modes are then
682
+ weighed as for a single family, by how well they explain all the families' phenotypes.
683
+
684
+ pedigrees: a dict of family to Pedigree, as read_ped gives, or a list of pedigrees
685
+ variants: the candidates, whose genotypes can cover the people of every family
686
+ probands: the proband of each family, if known (a dict of family to person)
687
+ options: passed on to rank_variants
688
+ """
689
+ if not isinstance(pedigrees, dict):
690
+ pedigrees = {p.family: p for p in pedigrees}
691
+ variants = list(variants)
692
+ probands = probands or {}
693
+ modes = _modes(options.get('modes', MODES))
694
+ priors = _prior(modes, options.get('prior'))
695
+ log_priors = {m: float(np.log(priors[m])) for m in modes if priors[m] > 0}
696
+ # each family's likelihood of its phenotypes under each mode without the candidate, and its candidates' scores
697
+ without: Dict[str, Dict[str, float]] = {}
698
+ found: Dict[Tuple[int, int], Dict[str, VariantScore]] = {}
699
+ for name, pedigree in pedigrees.items():
700
+ proband = probands.get(name)
701
+ without[name] = mode_log_likelihoods(pedigree, modes, options.get('penetrance', DEFAULT_PENETRANCE),
702
+ options.get('penetrance_weights'), options.get('phenocopy', DEFAULT_PHENOCOPY),
703
+ options.get('allele_frequency'), options.get('carrier_penetrance'),
704
+ [proband] if proband else None)
705
+ who = [proband] if proband else pedigree.affected()
706
+ for s in rank_variants(pedigree, variants, proband=proband, **options):
707
+ if s.partner is None and not any((s.variant.genotypes.get(p) or 0) >= 1 for p in who):
708
+ continue
709
+ if s.note != _IMPOSSIBLE and (s.log10_bf is None or not s.log_likelihood):
710
+ continue
711
+ found.setdefault((id(s.variant), id(s.partner)), {})[name] = s
712
+ log_all = _logsumexp([log_priors[m] + sum(without[f][m] for f in pedigrees) for m in log_priors])
713
+ combined = []
714
+ for by_family in found.values():
715
+ first = next(iter(by_family.values()))
716
+ c = CombinedScore(first.id, None, by_family)
717
+ combined.append(c)
718
+ unscored = [f for f, s in by_family.items() if s.note == _IMPOSSIBLE]
719
+ if unscored:
720
+ c.note = (f'its genotypes in {", ".join(unscored)} can\'t have been passed down without a genotyping '
721
+ 'error, and the error rate is 0, so it isn\'t scored')
722
+ continue
723
+ # the modes that suit the candidate in every family where it is scored: a mode under which its genotypes
724
+ # are impossible in one family (with an error rate of 0) is ruled out for them all
725
+ suited = [m for m in log_priors if all(m in s.log_likelihood for s in by_family.values())]
726
+ if not suited:
727
+ c.note = (f'its genotypes in {", ".join(by_family)} can\'t have been passed down under any one mode '
728
+ 'without a genotyping error, and the error rate is 0, so it isn\'t scored')
729
+ continue
730
+ ruled_out = [m for m in log_priors if any(m in s.impossible_modes for s in by_family.values())]
731
+ if ruled_out:
732
+ where = [f for f, s in by_family.items() if s.impossible_modes]
733
+ c.note = (f'{" and ".join(ruled_out)} {"is" if len(ruled_out) == 1 else "are"} left out for every '
734
+ f'family, since its genotypes in {", ".join(where)} can\'t have been passed down that way '
735
+ 'without a genotyping error, and the error rate is 0')
736
+ with_modes = [log_priors[m] + sum(by_family[f].log_likelihood[m] if f in by_family else without[f][m]
737
+ for f in pedigrees) for m in suited]
738
+ suited_without = [log_priors[m] + sum(without[f][m] for f in pedigrees) for m in suited]
739
+ log_with, log_suited = _logsumexp(with_modes), _logsumexp(suited_without)
740
+ if not (np.isfinite(log_suited) and np.isfinite(log_all)):
741
+ c.note = 'the families\' phenotypes rule out every mode that suits its chromosome'
742
+ else:
743
+ c.log10_bf = float((log_with - log_suited) / LN10)
744
+ c.mode_probability = float(np.exp(log_suited - log_all))
745
+ c.log10_odds = float((log_with - log_all) / LN10)
746
+ if any(s.evidence_withheld for s in by_family.values()):
747
+ c.evidence_withheld = next(s.evidence_withheld for s in by_family.values() if s.evidence_withheld)
748
+ for c, r in zip(combined, fair_ranks([c.log10_odds for c in combined])):
749
+ c.rank = r
750
+ combined.sort(key=lambda c: (c.rank is None, c.rank if c.rank is not None else 0))
751
+ return combined