PedNet 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pednet/__init__.py +24 -0
- pednet/__main__.py +5 -0
- pednet/_trained.py +29 -0
- pednet/cli.py +378 -0
- pednet/compound.py +180 -0
- pednet/engine.py +203 -0
- pednet/features.py +522 -0
- pednet/genotypes.py +253 -0
- pednet/graph.py +473 -0
- pednet/inheritance.py +262 -0
- pednet/models.py +209 -0
- pednet/pedigree.py +387 -0
- pednet/segregation.py +751 -0
- pednet/simulate.py +277 -0
- pednet-2.0.0.dist-info/METADATA +352 -0
- pednet-2.0.0.dist-info/RECORD +20 -0
- pednet-2.0.0.dist-info/WHEEL +5 -0
- pednet-2.0.0.dist-info/entry_points.txt +2 -0
- pednet-2.0.0.dist-info/licenses/LICENSE +21 -0
- pednet-2.0.0.dist-info/top_level.txt +1 -0
pednet/__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
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
|