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 ADDED
@@ -0,0 +1,24 @@
1
+ """PedNet: pedigrees as graphs. A pedigree is a directed acyclic graph, and PedNet analyses its structure (graph),
2
+ the pattern of affected people in it (features), and, with the graph as a Bayesian network of genotypes, the mode
3
+ of inheritance (inheritance) and the co-segregation of candidate variants (segregation)."""
4
+ from .features import (classify_by_metrics, classify_by_patterns, network_metrics, pattern_features,
5
+ segregation_network_score)
6
+ from .genotypes import read_genotype_table, read_vcf
7
+ from .graph import (inbreeding, kinship, kinship_matrix, loops, mating_graph, moral_graph, pedigree_graph,
8
+ relationship)
9
+ from .inheritance import InheritanceResult, ModeEvidence, infer_mode, infer_shared_mode
10
+ from .models import MODE_NAMES, MODES, Model
11
+ from .pedigree import Pedigree, PedigreeError, Person, pedigree_from_rows, read_ped, write_ped
12
+ from .segregation import (CombinedScore, Variant, VariantScore, evidence_strength, rank_variants,
13
+ rank_variants_across)
14
+
15
+ __version__ = '2.0.0'
16
+
17
+ __all__ = [
18
+ 'pedigree_graph', 'mating_graph', 'moral_graph', 'loops', 'kinship', 'kinship_matrix', 'inbreeding',
19
+ 'relationship', 'network_metrics', 'pattern_features', 'classify_by_metrics', 'classify_by_patterns',
20
+ 'segregation_network_score', 'infer_mode', 'infer_shared_mode', 'rank_variants', 'rank_variants_across',
21
+ 'evidence_strength', 'read_ped', 'write_ped', 'pedigree_from_rows', 'read_vcf', 'read_genotype_table', 'Pedigree',
22
+ 'Person', 'PedigreeError', 'Variant', 'VariantScore', 'CombinedScore', 'InheritanceResult', 'ModeEvidence',
23
+ 'Model', 'MODES', 'MODE_NAMES', '__version__',
24
+ ]
pednet/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
pednet/_trained.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ What the graph classifiers learnt from simulated families: written by benchmarks/train_classifiers.py
3
+ (--families 1000 --seed 2026), so not to be edited by hand.
4
+ """
5
+ import numpy as np
6
+
7
+ # the original PedNet's metric classifier: for each metric kept, (threshold, the mode above it, AUC)
8
+ METRIC_THRESHOLDS = {
9
+ 'ratio_aff_parent': (0.1111111111111111, 'AD', 0.9012114999999999),
10
+ 'gen_cov': (0.6, 'AD', 0.9238780000000001),
11
+ }
12
+
13
+ # the pattern classifier: a multinomial logistic regression on the standardised pattern features
14
+ PATTERN_MODES = ('AD', 'AR', 'XLR', 'XLD')
15
+ PATTERN_FEATURES = ('affected_share', 'affected_generations', 'vertical', 'skipped', 'male_to_male', 'father_daughters', 'father_sons', 'mother_sons', 'mother_daughters', 'male_share', 'sibship_share', 'consanguineous', 'x_kinship_ratio', 'log_affected', 'generations')
16
+ PATTERN_MEAN = [0.2288667395449686, 0.5803541666666686, 0.40144131520243254, 0.5167159261451486, 0.10125, 0.47170645118770116, 0.3994372391497424, 0.46893794820859014, 0.46218275197025294, 0.5515335644005488, 0.3258036546985266, 0.162, 1.6527039199006657, 1.371156159023878, 3.602]
17
+ PATTERN_SCALE = [0.15691928293100238, 0.2662495688246373, 0.2996787021857291, 0.3131688349901684, 0.30165947275032157, 0.17532353722532326, 0.1624887568864363, 0.14217934096949675, 0.13727154479049042, 0.2453081607271272, 0.19799178472520745, 0.3684508108282698, 0.9223493348315648, 0.4281489057315073, 0.9915119767304735]
18
+ PATTERN_WEIGHTS = [
19
+ [0.8092720131088182, 0.18992408312039233, 0.6857974684471309, 0.6519792503432646, -0.2504322379566621, 0.28036090058000485, -0.2083172247494853, 0.12851272377005746, -0.058472181775463594, -0.040368521292830264, -0.35714127586906425, 0.057240308641926925, -0.2113480362407416, -0.4682721888126473, 0.1528224133528207, 0.35448056670608313],
20
+ [-0.1670559522948906, -0.2414898621455553, -1.4588708403730801, -0.445048041335051, 0.29944889519867535, 0.3438390037250986, -0.13499945353916662, 0.4420631849699804, 0.057159195992491266, 0.2632533430722387, -0.5093368709639541, -0.009309500402416602, 0.10889788078714148, -0.20239079341476177, 0.08182431805373414, -0.3440431171243752],
21
+ [-1.020260535228255, -0.017737476951062557, -0.050290987569196956, -0.5441934340954122, 0.2869424333194238, 0.08099393226349302, -0.0627603008747609, -0.30717192437573515, -0.0647562773984802, -0.029521438535975924, 2.0010873461292147, -0.27222116287871, 0.01615158411027784, 0.25896598161465584, -0.21357803694818064, -0.2752612154112168],
22
+ [0.37804506628606654, 0.06930325597634003, 0.8233643594969503, 0.33726222508441567, -0.33595909055899875, -0.7051938365702919, 0.40607697916318464, -0.26340398436412177, 0.0660692631826541, -0.19336338324366237, -1.1346091992974519, 0.22429035463922664, 0.08629857134284158, 0.4116970006128527, -0.021068694459017993, 0.2648237658292406],
23
+ ]
24
+
25
+
26
+ def pattern_classifier():
27
+ from .features import PatternClassifier
28
+ return PatternClassifier(PATTERN_MODES, PATTERN_FEATURES, np.array(PATTERN_MEAN), np.array(PATTERN_SCALE),
29
+ np.array(PATTERN_WEIGHTS))
pednet/cli.py ADDED
@@ -0,0 +1,378 @@
1
+ """The pednet command:
2
+
3
+ pednet graph FAMILY.ped the pedigree as a graph: its structure, loops, kinship and patterns
4
+ pednet mode FAMILY.ped the mode of inheritance
5
+ pednet rank FAMILY.ped VARIANTS.vcf candidate variants ranked by co-segregation
6
+ pednet check FAMILY.ped what PedNet makes of a PED file
7
+ """
8
+ import argparse
9
+ import csv
10
+ import os
11
+ import sys
12
+ from typing import List, Optional
13
+
14
+ from . import __version__
15
+ from . import graph as pedgraph
16
+ from .features import classify_by_metrics, classify_by_patterns, network_metrics, pattern_features
17
+ from .genotypes import read_genotype_table, read_vcf, vcf_samples
18
+ from .inheritance import DEFAULT_PENETRANCE, DEFAULT_PHENOCOPY, infer_mode, infer_shared_mode
19
+ from .models import MODES
20
+ from .pedigree import PedigreeError, read_ped
21
+ from .segregation import DEFAULT_ERROR_RATE, MIN_VARIANT_FREQUENCY, rank_variants, rank_variants_across
22
+
23
+
24
+ def _floats(text: str) -> List[float]:
25
+ try:
26
+ return [float(x) for x in text.replace(',', ' ').split()]
27
+ except ValueError:
28
+ raise argparse.ArgumentTypeError(f'expected numbers separated by commas, not {text!r}')
29
+
30
+
31
+ def _families(args):
32
+ families = read_ped(args.ped, args.family)
33
+ if not families:
34
+ raise PedigreeError(f'There are no people in {args.ped}')
35
+ if getattr(args, 'proband', None) and len(families) > 1:
36
+ raise PedigreeError('--proband needs --family, since the PED file has more than one family')
37
+ return families
38
+
39
+
40
+ def _common(parser):
41
+ parser.add_argument('ped', help='the PED file')
42
+ parser.add_argument('--family', help='only this family (by default every family in the file)')
43
+ parser.add_argument('--proband', help='the person through whom the family was found (by default each affected '
44
+ 'person in turn)')
45
+ parser.add_argument('--modes', default=','.join(MODES),
46
+ help='the modes of inheritance to weigh up, separated by commas (default: all four)')
47
+ parser.add_argument('--penetrance', type=_floats, default=list(DEFAULT_PENETRANCE),
48
+ help='the penetrance, or several to average over (default: 0.6,0.8,0.95,0.99)')
49
+ parser.add_argument('--phenocopy', type=float, default=DEFAULT_PHENOCOPY,
50
+ help=f'the chance of being affected without the disease genotype (default: {DEFAULT_PHENOCOPY})')
51
+
52
+
53
+ def _write_tsv(rows, handle):
54
+ fields = []
55
+ for row in rows:
56
+ fields.extend(k for k in row if k not in fields)
57
+ writer = csv.DictWriter(handle, fieldnames=fields or ['candidate'], delimiter='\t', lineterminator='\n')
58
+ writer.writeheader()
59
+ writer.writerows(rows)
60
+
61
+
62
+ def _mode(args) -> int:
63
+ families = _families(args)
64
+ if args.method != 'likelihood':
65
+ if args.together:
66
+ raise ValueError('--together works with the likelihood method only')
67
+ return _mode_by_graph(args, families)
68
+ if args.together:
69
+ results = [infer_shared_mode(families, args.modes, args.penetrance, phenocopy=args.phenocopy,
70
+ threshold=args.threshold)]
71
+ else:
72
+ results = [infer_mode(pedigree, args.modes, args.penetrance, phenocopy=args.phenocopy, proband=args.proband,
73
+ threshold=args.threshold) for pedigree in families.values()]
74
+ if args.tsv:
75
+ _write_tsv([row for r in results for row in r.table()], sys.stdout)
76
+ return 0
77
+ for result in results:
78
+ print(result)
79
+ print()
80
+ return 0
81
+
82
+
83
+ def _mode_by_graph(args, families) -> int:
84
+ unused = [option for option, given in (
85
+ ('--proband', args.proband is not None), ('--modes', args.modes != ','.join(MODES)),
86
+ ('--penetrance', list(args.penetrance) != list(DEFAULT_PENETRANCE)),
87
+ ('--phenocopy', args.phenocopy != DEFAULT_PHENOCOPY),
88
+ ('--threshold', args.method == 'metrics' and args.threshold != 0.8)) if given]
89
+ if unused:
90
+ listed = unused[0] if len(unused) == 1 else ', '.join(unused[:-1]) + ' and ' + unused[-1]
91
+ classifier = 'pattern classifier' if args.method == 'patterns' else 'metric classifier'
92
+ print(f'note: the {classifier} learnt from simulated families and takes no account of {listed}, which '
93
+ 'the likelihood method uses', file=sys.stderr)
94
+ rows = []
95
+ for name, pedigree in families.items():
96
+ if args.method == 'patterns':
97
+ result = classify_by_patterns(pedigree, threshold=args.threshold)
98
+ rows.extend({'family': name, 'mode': m, 'probability': p} for m, p in result.probabilities.items())
99
+ text = str(result)
100
+ else:
101
+ call = classify_by_metrics(pedigree)
102
+ values = network_metrics(pedigree)
103
+ rows.append({'family': name, 'call': call, **values})
104
+ text = (f'Family {name}, by the original PedNet metric classifier: {call}\n ' +
105
+ ', '.join(f'{k} {v:.3f}' for k, v in values.items()))
106
+ if not args.tsv:
107
+ print(text)
108
+ print()
109
+ if args.tsv:
110
+ _write_tsv(rows, sys.stdout)
111
+ return 0
112
+
113
+
114
+ def _graph(args) -> int:
115
+ families = _families(args)
116
+ if (args.kinship or args.graphml) and len(families) > 1:
117
+ raise PedigreeError('--kinship and --graphml need --family, since the PED file has more than one family')
118
+ for name, pedigree in families.items():
119
+ graph = pedgraph.pedigree_graph(pedigree)
120
+ couples = {(p.father, p.mother) for p in pedigree.people if p.father is not None}
121
+ generations = pedigree.generations()
122
+ founders = [n for n in graph if graph.in_degree(n) == 0]
123
+ print(f'Family {name}: {_count(graph.number_of_nodes(), "person", "people")} in '
124
+ f'{_count(max(generations.values()) + 1, "generation")}, {len(pedigree.affected())} affected; '
125
+ f'{_count(len(founders), "founder")} and {_count(len(couples), "couple")} with children')
126
+ found = pedgraph.loops(pedigree)
127
+ if found:
128
+ for loop in found:
129
+ print(f' a loop, from {loop.kind}: {", ".join(loop.people)}')
130
+ else:
131
+ print(' no loops')
132
+ inbred = {pid: f for pid, f in pedgraph.inbreeding(pedigree).items() if f > 0}
133
+ if inbred:
134
+ print(' inbred: ' + ', '.join(f'{pid} (F = {f:.4f})' for pid, f in inbred.items()))
135
+ print(f' width {pedgraph.pedigree_width(pedigree)} (the most people no one of whom is another\'s ancestor)')
136
+ plan = pedgraph.elimination(pedigree)
137
+ print(f' exact likelihoods: elimination width {plan.width}, so the moral graph\'s treewidth is at most '
138
+ f'{plan.width - 1}' + ('' if plan.exact else
139
+ f'; too many loops for exact likelihoods (a table of {plan.largest_table:,} '
140
+ 'states)'))
141
+ affected = pedigree.affected()
142
+ pairs = [(a, b) for i, a in enumerate(affected) for b in affected[i + 1:]]
143
+ matrix = pedgraph.kinship_matrix(pedigree)
144
+ named = [(a, b, pedgraph.relationship(pedigree, a, b, matrix)) for a, b in pairs]
145
+ named = [x for x in named if x[2].degree is not None]
146
+ named.sort(key=lambda x: (-x[2].kinship, x[0], x[1]))
147
+ if named:
148
+ print(' affected relatives, the closest first:')
149
+ for a, b, r in named[:args.pairs]:
150
+ print(f' {b} is the {r.name} of {a} ({_ordinal_degree(r.degree)} degree, kinship {r.kinship:.4f})')
151
+ if len(named) > args.pairs:
152
+ print(f' ... and {len(named) - args.pairs} more pairs')
153
+ values = network_metrics(pedigree)
154
+ print(' network metrics (the original PedNet\'s):')
155
+ _print_values(values)
156
+ print(' pattern features:')
157
+ _print_values(pattern_features(pedigree))
158
+ central = sorted(pedgraph.centrality(pedigree).items(), key=lambda x: -x[1])[:3]
159
+ print(' most central (betweenness): ' + ', '.join(f'{pid} {v:.3f}' for pid, v in central))
160
+ if args.kinship:
161
+ ids, phi = pedgraph.kinship_matrix(pedigree, x_linked=args.x)
162
+ keep = [k for k, pid in enumerate(ids) if not pedigree[pid].placeholder]
163
+ with open(args.kinship, 'w', newline='') as handle:
164
+ writer = csv.writer(handle, delimiter='\t', lineterminator='\n')
165
+ writer.writerow([''] + [ids[k] for k in keep])
166
+ for k in keep:
167
+ writer.writerow([ids[k]] + [f'{phi[k, j]:.6g}' for j in keep])
168
+ print(f' wrote the {"X-linked " if args.x else ""}kinship coefficients to {args.kinship}')
169
+ if args.graphml:
170
+ pedgraph.write_graph(pedigree, args.graphml, mating=args.mating)
171
+ print(f' wrote the {"mating" if args.mating else "pedigree"} graph to {args.graphml}')
172
+ return 0
173
+
174
+
175
+ def _print_values(values, per_line=4):
176
+ items = [f'{k} {v:.3f}' for k, v in values.items()]
177
+ for start in range(0, len(items), per_line):
178
+ print(' ' + ', '.join(items[start:start + per_line]))
179
+
180
+
181
+ def _ordinal_degree(degree: int) -> str:
182
+ return {1: 'first', 2: 'second', 3: 'third', 4: 'fourth', 5: 'fifth'}.get(degree, f'{degree}th')
183
+
184
+
185
+ def _count(n: int, one: str, many: Optional[str] = None) -> str:
186
+ """'1 variant', '2 variants'"""
187
+ return f'{n} {one if n == 1 else (many or one + "s")}'
188
+
189
+
190
+ def _format(value, digits=2):
191
+ if value is None:
192
+ return '-'
193
+ if isinstance(value, float):
194
+ return f'{value:.{digits}f}'
195
+ return str(value)
196
+
197
+
198
+ def _print_table(header, table):
199
+ widths = [max(len(str(row[i])) for row in [header] + table) for i in range(len(header))]
200
+ for row in [header] + table:
201
+ print(' '.join(str(cell).ljust(w) for cell, w in zip(row, widths)).rstrip())
202
+
203
+
204
+ def _note_strangers(names, people):
205
+ """Say which genotyped samples are in no family, and stop if none of them is in one"""
206
+ missing = sorted(set(names) - set(people))
207
+ if missing and len(missing) == len(set(names)):
208
+ raise PedigreeError('None of the genotyped samples is in the PED file: their names must be the IDs of the '
209
+ f'PED file ({", ".join(missing[:3])}{"..." if len(missing) > 3 else ""})')
210
+ if missing:
211
+ print(f'note: {_count(len(missing), "genotyped sample")} {"is" if len(missing) == 1 else "are"} in no family '
212
+ f'of the PED file and {"is" if len(missing) == 1 else "are"} left out '
213
+ f'({", ".join(missing[:5])}{"..." if len(missing) > 5 else ""})', file=sys.stderr)
214
+
215
+
216
+ def _read_variants(args, people):
217
+ path = str(args.variants).lower()
218
+ if path.endswith(('.vcf', '.vcf.gz', '.vcf.bgz')):
219
+ _note_strangers(vcf_samples(args.variants), people)
220
+ variants = read_vcf(args.variants, samples=people, frequency_key=args.frequency_key,
221
+ passed_only=args.passed_only, gene_key=args.gene_key)
222
+ else:
223
+ variants = read_genotype_table(args.variants)
224
+ _note_strangers({pid for v in variants for pid in v.genotypes}, people)
225
+ if args.max_frequency is not None:
226
+ kept = [v for v in variants if v.allele_frequency is None or v.allele_frequency <= args.max_frequency]
227
+ if len(kept) < len(variants):
228
+ n = len(variants) - len(kept)
229
+ print(f'note: {_count(n, "variant")} with an allele frequency above {args.max_frequency} '
230
+ f'{"is" if n == 1 else "are"} left out', file=sys.stderr)
231
+ variants = kept
232
+ return variants
233
+
234
+
235
+ def _rank(args) -> int:
236
+ families = _families(args)
237
+ people = {pid for pedigree in families.values() for pid in pedigree.ids}
238
+ variants = _read_variants(args, people)
239
+ options = dict(modes=args.modes, penetrance=args.penetrance, phenocopy=args.phenocopy,
240
+ min_frequency=args.min_frequency, error_rate=args.error_rate, compound=not args.no_compound)
241
+ if len(families) > 1:
242
+ return _rank_across(args, families, variants, options)
243
+ pedigree = next(iter(families.values()))
244
+ scores = rank_variants(pedigree, variants, proband=args.proband, **options)
245
+ if args.out:
246
+ with open(args.out, 'w', newline='') as handle:
247
+ _write_tsv([s.row() for s in scores], handle)
248
+ shown = scores[:args.top] if args.top else scores
249
+ pairs = sum(s.compound for s in scores)
250
+ print(f'Family {pedigree.family}: {_count(len(scores) - pairs, "variant")}'
251
+ + (f' and {_count(pairs, "pair")} of variants in one gene' if pairs else '') + ', the most likely cause first')
252
+ header = ['rank', 'candidate', 'log10 odds', 'log10 BF', 'evidence', 'best mode', 'affected carriers',
253
+ 'unaffected carriers', 'note']
254
+ table = [[_format(s.rank, 1), s.id, _format(s.log10_odds), _format(s.log10_bf), s.evidence or '-',
255
+ (s.best_mode + ' (compound)' if s.compound and s.best_mode else s.best_mode) or '-',
256
+ f'{s.affected_carriers}/{s.affected_carriers + s.affected_noncarriers}',
257
+ f'{s.unaffected_carriers}/{s.unaffected_carriers + s.unaffected_noncarriers}', s.notes]
258
+ for s in shown]
259
+ _print_table(header, table)
260
+ if args.top and len(scores) > args.top:
261
+ print(f'... and {len(scores) - args.top} more' + (f' (all in {args.out})' if args.out else ''))
262
+ return 0
263
+
264
+
265
+ def _rank_across(args, families, variants, options) -> int:
266
+ combined = rank_variants_across(families, variants, **options)
267
+ if args.out:
268
+ with open(args.out, 'w', newline='') as handle:
269
+ _write_tsv([c.row() for c in combined], handle)
270
+ print(f'Families {", ".join(families)}: {_count(len(combined), "candidate")} carried by an affected person in at '
271
+ 'least one family, the most likely cause over the families first')
272
+ shown = combined[:args.top] if args.top else combined
273
+ header = ['rank', 'candidate', 'log10 odds', 'log10 BF', 'evidence', 'each family\'s own log10 BF']
274
+ table = [[_format(c.rank, 1), c.id, _format(c.log10_odds), _format(c.log10_bf), c.evidence or '-',
275
+ ', '.join(f'{name} {_format(s.log10_bf)}' for name, s in c.families.items())] for c in shown]
276
+ _print_table(header, table)
277
+ for c in shown:
278
+ if c.note:
279
+ print(f'note: {c.id}: {c.note}')
280
+ elif c.evidence_withheld:
281
+ print(f'note: no evidence is given for {c.id}: {c.evidence_withheld}')
282
+ if args.top and len(combined) > args.top:
283
+ print(f'... and {len(combined) - args.top} more' + (f' (all in {args.out})' if args.out else ''))
284
+ return 0
285
+
286
+
287
+ def _check(args) -> int:
288
+ for name, pedigree in _families(args).items():
289
+ generations = pedigree.generations()
290
+ print(f'Family {name}: {len([p for p in pedigree.people if not p.placeholder])} people, '
291
+ f'{len(pedigree.affected())} affected, {max(generations.values()) + 1} generations')
292
+ related = pedigree.consanguineous()
293
+ if related:
294
+ print(f' children of related parents: {", ".join(sorted(related))}')
295
+ for note in pedigree.notes + pedigree.problems():
296
+ print(f' note: {note}')
297
+ return 0
298
+
299
+
300
+ def main(argv: Optional[List[str]] = None) -> int:
301
+ parser = argparse.ArgumentParser(prog='pednet', description='Pedigrees as graphs: the structure of a family, '
302
+ 'the mode of inheritance of its condition, and the co-segregation of candidate '
303
+ 'variants with it.')
304
+ parser.add_argument('--version', action='version', version=f'pednet {__version__}')
305
+ sub = parser.add_subparsers(dest='command', required=True)
306
+
307
+ graph = sub.add_parser('graph', help='the pedigree as a graph: its structure, loops, kinship and patterns')
308
+ graph.add_argument('ped', help='the PED file')
309
+ graph.add_argument('--family', help='only this family')
310
+ graph.add_argument('--pairs', type=int, default=10, help='how many pairs of affected relatives to list (default 10)')
311
+ graph.add_argument('--kinship', help='write the kinship coefficients of everyone to this file, separated by tabs')
312
+ graph.add_argument('--x', action='store_true', help='with --kinship, the coefficients for the X chromosome')
313
+ graph.add_argument('--graphml', help='write the graph as GraphML, for network tools such as Gephi or Cytoscape')
314
+ graph.add_argument('--mating', action='store_true',
315
+ help='with --graphml, write the mating graph, with a node for each couple')
316
+ graph.set_defaults(run=_graph)
317
+
318
+ mode = sub.add_parser('mode', help='the probability of each mode of inheritance')
319
+ _common(mode)
320
+ mode.add_argument('--method', choices=('likelihood', 'patterns', 'metrics'), default='likelihood',
321
+ help='likelihood: the exact likelihood of the phenotypes under each mode, summed over '
322
+ 'everyone\'s genotypes (the default); patterns: the classifier of the pattern of affected '
323
+ 'people in the graph; metrics: the original PedNet\'s network metric classifier, AD or AR')
324
+ mode.add_argument('--threshold', type=float, default=0.8,
325
+ help='the probability needed to call a mode (default: 0.8)')
326
+ mode.add_argument('--together', action='store_true',
327
+ help='take the families as having the same condition, and give one answer for them all')
328
+ mode.add_argument('--tsv', action='store_true', help='print a table separated by tabs')
329
+ mode.set_defaults(run=_mode)
330
+
331
+ rank = sub.add_parser('rank', help='candidate variants ranked by co-segregation with the condition',
332
+ description='Ranks candidate variants by how well they co-segregate with the condition. '
333
+ 'With several families in the PED file (and no --family), the families are taken to have '
334
+ 'the same condition, with one mode of inheritance, and each candidate is ranked by its '
335
+ 'evidence over the families in which an affected person carries it: the sum of their log10 '
336
+ 'Bayes factors when the mode is given (--modes AD, say), and otherwise more than the sum if '
337
+ 'the families agree on the mode, and less if they point to different modes.')
338
+ _common(rank)
339
+ rank.add_argument('variants', help='a VCF file (.vcf or .vcf.gz), or a table with a column per person')
340
+ rank.add_argument('--frequency-key', default='AF', help='the INFO field with the allele frequency (default: AF)')
341
+ rank.add_argument('--gene-key', help='the INFO field naming the gene (default: the first of CSQ, ANN, BCSQ, GENE, '
342
+ 'SYMBOL, Gene.refGene, Gene.ensGene and GENEINFO present)')
343
+ rank.add_argument('--passed-only', action='store_true', help='keep only VCF sites that passed their filters')
344
+ rank.add_argument('--max-frequency', type=float,
345
+ help='leave out variants with an allele frequency above this (by default none are left out)')
346
+ rank.add_argument('--min-frequency', type=float, default=MIN_VARIANT_FREQUENCY,
347
+ help=f'treat rarer variants as having this frequency (default: {MIN_VARIANT_FREQUENCY})')
348
+ rank.add_argument('--error-rate', type=float, default=DEFAULT_ERROR_RATE,
349
+ help=f'the chance that a genotype call is one copy out (default: {DEFAULT_ERROR_RATE})')
350
+ rank.add_argument('--no-compound', action='store_true',
351
+ help='don\'t score pairs of variants in one gene as compound heterozygotes')
352
+ rank.add_argument('--top', type=int, default=20, help='how many to show (default: 20; 0 for all)')
353
+ rank.add_argument('--out', help='write every candidate\'s scores to this file, separated by tabs')
354
+ rank.set_defaults(run=_rank)
355
+
356
+ check = sub.add_parser('check', help='what PedNet makes of a PED file')
357
+ check.add_argument('ped', help='the PED file')
358
+ check.add_argument('--family', help='only this family')
359
+ check.set_defaults(run=_check)
360
+
361
+ args = parser.parse_args(argv)
362
+ try:
363
+ status = args.run(args)
364
+ sys.stdout.flush() # here rather than as Python exits, so that a reader who has stopped is noticed below
365
+ return status
366
+ except BrokenPipeError:
367
+ # whatever was reading the output (head, say) has stopped: stop quietly, sending what is left to nowhere so
368
+ # that Python doesn't complain again as it exits
369
+ devnull = os.open(os.devnull, os.O_WRONLY)
370
+ os.dup2(devnull, sys.stdout.fileno())
371
+ return 1
372
+ except (PedigreeError, ValueError, OSError) as error:
373
+ print(f'pednet: {error}', file=sys.stderr)
374
+ return 1
375
+
376
+
377
+ if __name__ == '__main__':
378
+ sys.exit(main())
pednet/compound.py ADDED
@@ -0,0 +1,180 @@
1
+ """Pairs of variants in one gene, as a compound heterozygote.
2
+
3
+ A recessive condition is often caused by two different variants in the same gene, one on each copy of the gene.
4
+ Neither variant then segregates as a recessive variant should (the affected people have one copy of each, not two
5
+ of either), so PedNet also scores the pairs of variants in a gene that an affected person carries both of.
6
+
7
+ The gene is then a locus with four haplotypes: a copy of the gene with neither variant (r), with the first only
8
+ (a), with the second only (b), or with both (c, the two variants in cis). Someone's genotype is a pair of
9
+ haplotypes, which gives ten states; on the X chromosome a man has a single haplotype, four states. Under a
10
+ recessive mode the disease genotype is two copies that each carry a variant (on the X, a man's one copy). In a
11
+ founder the haplotypes have the frequencies they would have in linkage equilibrium. Two rare variants on one copy
12
+ (cis) are then about as likely in a founder as one on each copy (trans), and the family's genotypes show which
13
+ they are: in cis, the two are passed on together.
14
+ """
15
+ from functools import lru_cache
16
+ from typing import TYPE_CHECKING, Dict, Optional, Sequence, Tuple
17
+
18
+ import numpy as np
19
+
20
+ from .engine import Network
21
+ from .inheritance import _logsumexp
22
+ from .models import Model, call_codes, call_probability
23
+ from .pedigree import Pedigree
24
+
25
+ if TYPE_CHECKING:
26
+ from .segregation import Variant
27
+
28
+ # the copies of the first and of the second variant on each haplotype: r, a, b and c
29
+ HAPLOTYPES = ((0, 0), (1, 0), (0, 1), (1, 1))
30
+ PAIRS = tuple((i, j) for i in range(4) for j in range(i, 4))
31
+ PAIR_MODES = ('AR', 'XLR')
32
+
33
+
34
+ @lru_cache(maxsize=None)
35
+ def _states(kind: str) -> Tuple[Tuple[Tuple[int, ...], bool], ...]:
36
+ """Each state of a genotype of this kind, as its haplotypes and whether it is a man's"""
37
+ men = tuple(((h,), True) for h in range(4))
38
+ pairs = tuple((pair, False) for pair in PAIRS)
39
+ return {'A': pairs, 'F': pairs, 'M': men, 'U': men + pairs}[kind]
40
+
41
+
42
+ class PairLocus:
43
+ """The four haplotypes of a pair of variants in one gene, as a locus for Network"""
44
+
45
+ @staticmethod
46
+ def domain(kind: str) -> int:
47
+ return len(_states(kind))
48
+
49
+ @staticmethod
50
+ def transmission(kind: str) -> np.ndarray:
51
+ return _transmission(kind)
52
+
53
+
54
+ @lru_cache(maxsize=None)
55
+ def _transmission(kind: str) -> np.ndarray:
56
+ """P(child's state | father's state, mother's state), indexed [child, father, mother], as in models.transmission"""
57
+ n = len(PAIRS)
58
+ position = {pair: k for k, pair in enumerate(PAIRS)}
59
+ if kind == 'A':
60
+ t = np.zeros((n, n, n))
61
+ for f, father in enumerate(PAIRS):
62
+ for m, mother in enumerate(PAIRS):
63
+ for a in father:
64
+ for b in mother:
65
+ t[position[tuple(sorted((a, b)))], f, m] += 0.25
66
+ return t
67
+ son = np.zeros((4, 4, n))
68
+ daughter = np.zeros((n, 4, n))
69
+ for h in range(4):
70
+ for m, mother in enumerate(PAIRS):
71
+ for b in mother:
72
+ # a son has one of his mother's copies; a daughter her father's only copy and one of her mother's
73
+ son[b, h, m] += 0.5
74
+ daughter[position[tuple(sorted((h, b)))], h, m] += 0.5
75
+ if kind == 'M':
76
+ return son
77
+ if kind == 'F':
78
+ return daughter
79
+ return np.concatenate([0.5 * son, 0.5 * daughter], axis=0)
80
+
81
+
82
+ def founder_prior(kind: str, q1, q2) -> np.ndarray:
83
+ """The founder genotype probabilities, for variant frequencies q1 and q2 (single values, or one per batch)"""
84
+ q1, q2 = np.asarray(q1, dtype=float), np.asarray(q2, dtype=float)
85
+ hap = np.stack([(1 - q1) * (1 - q2), q1 * (1 - q2), (1 - q1) * q2, q1 * q2], axis=-1)
86
+ diploid = np.stack([hap[..., i] * hap[..., j] * (1.0 if i == j else 2.0) for i, j in PAIRS], axis=-1)
87
+ if kind in ('A', 'F'):
88
+ return diploid
89
+ if kind == 'M':
90
+ return hap
91
+ return np.concatenate([0.5 * hap, 0.5 * diploid], axis=-1)
92
+
93
+
94
+ @lru_cache(maxsize=None)
95
+ def genotype_table(kind: str, error_rate: float) -> np.ndarray:
96
+ """genotype_factor for every pair of calls, indexed by the codes of models.call_codes; read-only"""
97
+ calls = (None, 0, 1, 2)
98
+ table = np.stack([np.stack([genotype_factor(kind, a, b, error_rate) for b in calls]) for a in calls])
99
+ table.flags.writeable = False
100
+ return table
101
+
102
+
103
+ def genotype_factor(kind: str, called1: Optional[int], called2: Optional[int], error_rate: float) -> np.ndarray:
104
+ """P(the two genotype calls) for each state, with the error model of models.genotype_factor for each call"""
105
+ out = np.ones(len(_states(kind)))
106
+ for k, (haps, male) in enumerate(_states(kind)):
107
+ if called1 is not None:
108
+ out[k] *= call_probability(sum(HAPLOTYPES[h][0] for h in haps), male, called1, error_rate)
109
+ if called2 is not None:
110
+ out[k] *= call_probability(sum(HAPLOTYPES[h][1] for h in haps), male, called2, error_rate)
111
+ return out
112
+
113
+
114
+ def _damaged(haps) -> int:
115
+ """How many of the copies carry a variant"""
116
+ return sum(1 for h in haps if h != 0)
117
+
118
+
119
+ def affected_probability(model: Model, kind: str) -> np.ndarray:
120
+ """P(affected) for each state, under a recessive mode (AR, or XLR with its carrier penetrance for women)"""
121
+ f, p = model.penetrance, model.phenocopy
122
+ h = p if model.carrier_penetrance is None else model.carrier_penetrance
123
+ out = []
124
+ for haps, male in _states(kind):
125
+ damaged = _damaged(haps)
126
+ if male:
127
+ out.append(f if damaged else p)
128
+ elif model.mode == 'XLR':
129
+ out.append(f if damaged == 2 else (h if damaged == 1 else p))
130
+ else:
131
+ out.append(f if damaged == 2 else p)
132
+ return np.array(out)
133
+
134
+
135
+ def phenotype_factor(model: Model, kind: str, phenotype: int) -> np.ndarray:
136
+ affected = affected_probability(model, kind)
137
+ if phenotype == 2:
138
+ return affected
139
+ if phenotype == 1:
140
+ return 1 - affected
141
+ return np.ones(len(affected))
142
+
143
+
144
+ def has_disease_genotype(copies1: int, copies2: int, male: Optional[bool]) -> bool:
145
+ """Whether genotype calls fit the disease genotype of a compound heterozygote (or a homozygote of either)"""
146
+ if male:
147
+ return copies1 >= 1 or copies2 >= 1
148
+ return (copies1 >= 1 and copies2 >= 1) or copies1 == 2 or copies2 == 2
149
+
150
+
151
+ def pair_log_likelihoods(pedigree: Pedigree, net: Network, pairs: Sequence[Tuple['Variant', 'Variant']],
152
+ frequencies: Sequence[Tuple[float, float]], modes: Sequence[str], models: Dict[str, Model],
153
+ pens: Sequence[float], log_w: np.ndarray, error_rate: float) -> Dict[str, np.ndarray]:
154
+ """
155
+ For each mode, the natural log of P(phenotypes | both pairs' genotypes, the pair causes the condition) for each
156
+ pair, averaged over the penetrances with weights exp(log_w). net must be a Network with a PairLocus.
157
+ """
158
+ V, F = len(pairs), len(pens)
159
+ q1 = np.array([f[0] for f in frequencies])
160
+ q2 = np.array([f[1] for f in frequencies])
161
+ first, second = [a for a, _ in pairs], [b for _, b in pairs]
162
+ evidence = []
163
+ for i, (kind, person) in enumerate(zip(net.kinds, pedigree.people)):
164
+ e = genotype_table(kind, error_rate)[call_codes(first, person.id), call_codes(second, person.id)]
165
+ if net.parents[i] is None:
166
+ e = e * founder_prior(kind, q1, q2)
167
+ evidence.append(e)
168
+ log_genotypes = net.log_likelihood(evidence)
169
+ out = {}
170
+ for mode in modes:
171
+ model = models[mode]
172
+ unary = []
173
+ for i, (kind, person) in enumerate(zip(net.kinds, pedigree.people)):
174
+ phen = np.stack([phenotype_factor(model.with_(penetrance=f), kind, person.phenotype) for f in pens])
175
+ unary.append((phen[:, None, :] * evidence[i][None, :, :]).reshape(F * V, -1))
176
+ joint = net.log_likelihood(unary).reshape(F, V)
177
+ with np.errstate(invalid='ignore'):
178
+ # NaN where the genotypes have no probability at all, which the caller reports
179
+ out[mode] = _logsumexp(joint + log_w[:, None], axis=0) - log_genotypes
180
+ return out