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/genotypes.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Reading candidate variants' genotypes from a VCF file or a table."""
|
|
2
|
+
import csv
|
|
3
|
+
import gzip
|
|
4
|
+
import io
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from typing import Dict, Iterable, List, Optional
|
|
7
|
+
|
|
8
|
+
from .segregation import Variant
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _open_text(path):
|
|
12
|
+
"""A plain or gzipped text file, read as UTF-8 with or without the byte-order mark Excel writes"""
|
|
13
|
+
with open(path, 'rb') as handle:
|
|
14
|
+
magic = handle.read(2)
|
|
15
|
+
if magic == b'\x1f\x8b':
|
|
16
|
+
return io.TextIOWrapper(gzip.open(path), encoding='utf-8-sig')
|
|
17
|
+
return open(path, encoding='utf-8-sig')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _unique_ids(variants: List[Variant]) -> List[Variant]:
|
|
21
|
+
"""
|
|
22
|
+
Makes every variant's ID its own. An ID shared by several variants, as when bcftools norm -m- splits a site with
|
|
23
|
+
two alternate alleles and keeps its rsID on both, has the variant's place added (rs9 1:100:A>T), or failing that
|
|
24
|
+
a number
|
|
25
|
+
"""
|
|
26
|
+
counts = Counter(v.id for v in variants)
|
|
27
|
+
seen: Counter = Counter()
|
|
28
|
+
for v in variants:
|
|
29
|
+
if counts[v.id] > 1:
|
|
30
|
+
if v.chrom is not None and v.pos is not None:
|
|
31
|
+
v.id = f'{v.id} {v.chrom}:{v.pos}:{v.ref}>{v.alt}'
|
|
32
|
+
else:
|
|
33
|
+
seen[v.id] += 1
|
|
34
|
+
v.id = f'{v.id} ({seen[v.id]})'
|
|
35
|
+
if len({v.id for v in variants}) < len(variants):
|
|
36
|
+
seen = Counter()
|
|
37
|
+
for v in variants:
|
|
38
|
+
seen[v.id] += 1
|
|
39
|
+
if seen[v.id] > 1:
|
|
40
|
+
v.id = f'{v.id} ({seen[v.id]})'
|
|
41
|
+
return variants
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _copies(call: str, allele: int = 1) -> Optional[int]:
|
|
45
|
+
"""The number of copies of an allele in a genotype call such as 0/1, 1|1, 1 or ./. (None if missing)"""
|
|
46
|
+
call = call.strip()
|
|
47
|
+
if call in ('', '.', 'NA', 'nan'):
|
|
48
|
+
return None
|
|
49
|
+
alleles = call.replace('|', '/').split('/')
|
|
50
|
+
if any(a in ('.', '') for a in alleles):
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
return sum(int(a) == allele for a in alleles)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_COUNTS = {'0': 0, '1': 1, '2': 2, '0.0': 0, '1.0': 1, '2.0': 2}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _table_genotype(value: str, where: str = '') -> Optional[int]:
|
|
62
|
+
"""
|
|
63
|
+
A genotype in a table: a count of alternate alleles (0, 1 or 2, or 0.0, 1.0 or 2.0) or a call such as 0/1 or 1|1,
|
|
64
|
+
with '', '.', 'NA' and ./. for no call. Anything else is an error, named by where.
|
|
65
|
+
"""
|
|
66
|
+
value = str(value).strip()
|
|
67
|
+
if value in ('', '.', 'NA', 'nan', './.', '.|.', '-'):
|
|
68
|
+
return None
|
|
69
|
+
if value in _COUNTS:
|
|
70
|
+
return _COUNTS[value]
|
|
71
|
+
alleles = value.replace('|', '/').split('/')
|
|
72
|
+
if len(alleles) == 2 and all(a.isdigit() for a in alleles):
|
|
73
|
+
return sum(a != '0' for a in alleles)
|
|
74
|
+
if len(alleles) == 2 and all(a == '.' or a.isdigit() for a in alleles):
|
|
75
|
+
return None
|
|
76
|
+
raise ValueError(f'{where}{" " if where else ""}has the genotype {value!r}, which isn\'t 0, 1 or 2 copies or a '
|
|
77
|
+
'call such as 0/1')
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _frequency(value: Optional[str], where: str) -> Optional[float]:
|
|
81
|
+
if value is None or value.strip().lower() in ('', '.', 'na', 'nan', '-'):
|
|
82
|
+
return None
|
|
83
|
+
try:
|
|
84
|
+
frequency = float(value)
|
|
85
|
+
except ValueError:
|
|
86
|
+
raise ValueError(f'{where} has the allele frequency {value!r}, which isn\'t a number') from None
|
|
87
|
+
if not 0 <= frequency <= 1:
|
|
88
|
+
raise ValueError(f'{where} has the allele frequency {value!r}, which isn\'t between 0 and 1')
|
|
89
|
+
return frequency
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# INFO fields that may name a variant's gene, in the order they are looked for: those of VEP, SnpEff and bcftools csq,
|
|
93
|
+
# then single fields, as from ANNOVAR or dbSNP
|
|
94
|
+
GENE_KEYS = ('CSQ', 'ANN', 'BCSQ', 'GENE', 'SYMBOL', 'Gene.refGene', 'Gene.ensGene', 'GENEINFO')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_vcf(path, samples: Optional[Iterable[str]] = None, frequency_key: str = 'AF',
|
|
98
|
+
passed_only: bool = False, gene_key: Optional[str] = None) -> List[Variant]:
|
|
99
|
+
"""
|
|
100
|
+
Reads candidate variants from a VCF file (plain or gzipped). Each alternate allele of a site becomes a variant
|
|
101
|
+
of its own, with each sample's number of copies of it from the GT field. The allele frequency is taken from
|
|
102
|
+
the INFO field frequency_key (AF by default; for example gnomAD's AF_joint, if the VCF was annotated with it).
|
|
103
|
+
|
|
104
|
+
samples: keep only these samples (for example the people in a pedigree)
|
|
105
|
+
passed_only: keep only sites whose FILTER is PASS or '.'
|
|
106
|
+
gene_key: the INFO field naming the gene, which pairs variants up as compound heterozygotes. By default the
|
|
107
|
+
first of GENE_KEYS present: VEP's CSQ (its SYMBOL), SnpEff's ANN, bcftools csq's BCSQ, or a field such as
|
|
108
|
+
GENE, Gene.refGene (ANNOVAR) or GENEINFO (dbSNP)
|
|
109
|
+
"""
|
|
110
|
+
wanted = set(samples) if samples is not None else None
|
|
111
|
+
variants: List[Variant] = []
|
|
112
|
+
names: List[str] = []
|
|
113
|
+
csq_fields: List[str] = []
|
|
114
|
+
with _open_text(path) as handle:
|
|
115
|
+
for line in handle:
|
|
116
|
+
if line.startswith('##'):
|
|
117
|
+
if line.startswith('##INFO=<ID=CSQ,') and 'Format:' in line:
|
|
118
|
+
csq_fields = line.split('Format:', 1)[1].strip().rstrip('">').strip().split('|')
|
|
119
|
+
continue
|
|
120
|
+
if line.startswith('#'):
|
|
121
|
+
names = line.rstrip('\n\r').split('\t')[9:]
|
|
122
|
+
continue
|
|
123
|
+
fields = line.rstrip('\n\r').split('\t')
|
|
124
|
+
if len(fields) < 8:
|
|
125
|
+
continue
|
|
126
|
+
chrom, pos, vid, ref, alts, _, filt, info = fields[:8]
|
|
127
|
+
if passed_only and filt not in ('PASS', '.'):
|
|
128
|
+
continue
|
|
129
|
+
fmt = fields[8].split(':') if len(fields) > 8 else []
|
|
130
|
+
gt = fmt.index('GT') if 'GT' in fmt else None
|
|
131
|
+
frequencies = _info_values(info, frequency_key)
|
|
132
|
+
items = _info_items(info)
|
|
133
|
+
alt_list = alts.split(',')
|
|
134
|
+
for a, alt in enumerate(alt_list, start=1):
|
|
135
|
+
if alt in ('.', '*'):
|
|
136
|
+
continue
|
|
137
|
+
genotypes: Dict[str, Optional[int]] = {}
|
|
138
|
+
if gt is not None:
|
|
139
|
+
for name, sample in zip(names, fields[9:]):
|
|
140
|
+
if wanted is not None and name not in wanted:
|
|
141
|
+
continue
|
|
142
|
+
parts = sample.split(':')
|
|
143
|
+
genotypes[name] = _copies(parts[gt], a) if gt < len(parts) else None
|
|
144
|
+
if vid not in ('.', '') and len(alt_list) == 1:
|
|
145
|
+
identifier = vid
|
|
146
|
+
else:
|
|
147
|
+
identifier = f'{chrom}:{pos}:{ref}>{alt}'
|
|
148
|
+
frequency = frequencies[a - 1] if len(frequencies) >= a else None
|
|
149
|
+
gene = _gene(items, alt, len(alt_list), gene_key, csq_fields)
|
|
150
|
+
variants.append(Variant(identifier, genotypes, chrom, int(pos), ref, alt, frequency, gene))
|
|
151
|
+
return _unique_ids(variants)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def vcf_samples(path) -> List[str]:
|
|
155
|
+
"""The names of a VCF file's samples, from its header"""
|
|
156
|
+
with _open_text(path) as handle:
|
|
157
|
+
for line in handle:
|
|
158
|
+
if line.startswith('#CHROM'):
|
|
159
|
+
return line.rstrip('\n\r').split('\t')[9:]
|
|
160
|
+
if not line.startswith('#'):
|
|
161
|
+
break
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _info_items(info: str) -> Dict[str, str]:
|
|
166
|
+
items = {}
|
|
167
|
+
for item in info.split(';'):
|
|
168
|
+
key, _, value = item.partition('=')
|
|
169
|
+
items.setdefault(key, value)
|
|
170
|
+
return items
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _gene(items: Dict[str, str], alt: str, n_alts: int, key: Optional[str], csq_fields: List[str]) -> Optional[str]:
|
|
174
|
+
"""The gene an alternate allele is in, from the INFO field key (or the first of GENE_KEYS present)"""
|
|
175
|
+
for k in ([key] if key else GENE_KEYS):
|
|
176
|
+
value = items.get(k)
|
|
177
|
+
if not value or value == '.':
|
|
178
|
+
continue
|
|
179
|
+
if k in ('CSQ', 'ANN', 'BCSQ'):
|
|
180
|
+
if k == 'CSQ':
|
|
181
|
+
position = csq_fields.index('SYMBOL') if 'SYMBOL' in csq_fields else 3
|
|
182
|
+
else:
|
|
183
|
+
position = 3 if k == 'ANN' else 1
|
|
184
|
+
matched, others = None, None
|
|
185
|
+
for annotation in value.split(','):
|
|
186
|
+
parts = annotation.split('|')
|
|
187
|
+
if len(parts) <= position or parts[position] in ('', '.'):
|
|
188
|
+
continue
|
|
189
|
+
# an annotation of this allele (ANN and CSQ start with it), or else any
|
|
190
|
+
if k != 'BCSQ' and n_alts > 1 and parts[0] == alt:
|
|
191
|
+
matched = matched or parts[position]
|
|
192
|
+
others = others or parts[position]
|
|
193
|
+
gene = matched or others
|
|
194
|
+
elif k == 'GENEINFO':
|
|
195
|
+
gene = value.split('|')[0].split(':')[0]
|
|
196
|
+
else:
|
|
197
|
+
gene = value.replace('\\x3b', ',').replace('\\x3B', ',').split(',')[0]
|
|
198
|
+
if gene and gene != '.':
|
|
199
|
+
return gene
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _info_values(info: str, key: str) -> List[Optional[float]]:
|
|
204
|
+
for item in info.split(';'):
|
|
205
|
+
if item.startswith(key + '='):
|
|
206
|
+
out = []
|
|
207
|
+
for value in item[len(key) + 1:].split(','):
|
|
208
|
+
try:
|
|
209
|
+
out.append(float(value))
|
|
210
|
+
except ValueError:
|
|
211
|
+
out.append(None)
|
|
212
|
+
return out
|
|
213
|
+
return []
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def read_genotype_table(path, delimiter: Optional[str] = None) -> List[Variant]:
|
|
217
|
+
"""
|
|
218
|
+
Reads candidate variants from a table with one row per variant: a column variant (or id) naming it, optional
|
|
219
|
+
columns chrom, pos, ref, alt, af (its allele frequency) and gene, and a column for each person, holding the number
|
|
220
|
+
of copies of the alternate allele (0, 1 or 2) or a call such as 0/1. Empty cells and ./. mean not genotyped.
|
|
221
|
+
The columns are separated by tabs, unless the file ends in .csv or delimiter says otherwise.
|
|
222
|
+
"""
|
|
223
|
+
if delimiter is None:
|
|
224
|
+
delimiter = ',' if str(path).lower().endswith('.csv') else '\t'
|
|
225
|
+
known = {'variant', 'id', 'chrom', 'chr', 'chromosome', 'pos', 'position', 'ref', 'alt', 'af', 'allele_frequency',
|
|
226
|
+
'gene', 'symbol'}
|
|
227
|
+
variants = []
|
|
228
|
+
with _open_text(path) as handle:
|
|
229
|
+
reader = csv.DictReader(handle, delimiter=delimiter)
|
|
230
|
+
if reader.fieldnames is None:
|
|
231
|
+
return []
|
|
232
|
+
lower = {name: name.strip().lower() for name in reader.fieldnames}
|
|
233
|
+
people = [name for name in reader.fieldnames if lower[name] not in known]
|
|
234
|
+
def column(row, *keys):
|
|
235
|
+
for name in reader.fieldnames:
|
|
236
|
+
if lower[name] in keys and row.get(name) not in (None, ''):
|
|
237
|
+
return row[name].strip()
|
|
238
|
+
return None
|
|
239
|
+
for number, row in enumerate(reader, start=1):
|
|
240
|
+
identifier = column(row, 'variant', 'id') or f'variant {number}'
|
|
241
|
+
where = f'Row {number} ({identifier})'
|
|
242
|
+
pos = column(row, 'pos', 'position')
|
|
243
|
+
try:
|
|
244
|
+
position = int(pos) if pos else None
|
|
245
|
+
except ValueError:
|
|
246
|
+
raise ValueError(f'{where} has the position {pos!r}, which isn\'t a whole number') from None
|
|
247
|
+
genotypes = {p.strip(): _table_genotype(row[p] if row[p] is not None else '', f'{where}, column {p},')
|
|
248
|
+
for p in people}
|
|
249
|
+
variants.append(Variant(identifier, genotypes, column(row, 'chrom', 'chr', 'chromosome'), position,
|
|
250
|
+
column(row, 'ref'), column(row, 'alt'),
|
|
251
|
+
_frequency(column(row, 'af', 'allele_frequency'), where),
|
|
252
|
+
column(row, 'gene', 'symbol')))
|
|
253
|
+
return _unique_ids(variants)
|