telinker 0.1.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.
- telinker/__init__.py +7 -0
- telinker/__main__.py +3 -0
- telinker/cli.py +190 -0
- telinker/impute.py +51 -0
- telinker/inputs.py +275 -0
- telinker/match.py +47 -0
- telinker/output.py +59 -0
- telinker/panel/PANEL.md +14 -0
- telinker/panel/loci.tsv.gz +0 -0
- telinker/panel/models.tsv.gz +0 -0
- telinker/panel.py +90 -0
- telinker/reads.py +163 -0
- telinker-0.1.0.dist-info/METADATA +108 -0
- telinker-0.1.0.dist-info/RECORD +18 -0
- telinker-0.1.0.dist-info/WHEEL +5 -0
- telinker-0.1.0.dist-info/entry_points.txt +2 -0
- telinker-0.1.0.dist-info/licenses/LICENSE +21 -0
- telinker-0.1.0.dist-info/top_level.txt +1 -0
telinker/__init__.py
ADDED
telinker/__main__.py
ADDED
telinker/cli.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
from collections import Counter
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from . import __version__, log
|
|
9
|
+
from .impute import impute
|
|
10
|
+
from .inputs import read_bed, read_plink, read_raw, read_vcf, sample_name, sniff
|
|
11
|
+
from .match import match
|
|
12
|
+
from .output import PRESENT, write_tsv, write_vcf
|
|
13
|
+
from .panel import BUILDS, PANEL_DIR, load_panel
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv=None):
|
|
17
|
+
ap = argparse.ArgumentParser(
|
|
18
|
+
prog="telinker", description="Genotype polymorphic TEs from VCF, PLINK, array, BED "
|
|
19
|
+
"or BAM/CRAM files.")
|
|
20
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
21
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
22
|
+
|
|
23
|
+
g = sub.add_parser("genotype", help="genotype catalog polyTEs")
|
|
24
|
+
g.add_argument("input", nargs="+",
|
|
25
|
+
help="VCF, PLINK prefix, 23andMe/Ancestry file, BED of TE calls or BAM/CRAM; "
|
|
26
|
+
"several inputs are merged by sample name")
|
|
27
|
+
g.add_argument("-o", "--out", help="output file (default: stdout)")
|
|
28
|
+
g.add_argument("--build", choices=("auto",) + BUILDS, default="auto",
|
|
29
|
+
help="genome build of the inputs (default: from the header)")
|
|
30
|
+
g.add_argument("--min-r2", type=float, default=0.5,
|
|
31
|
+
help="minimum model cv r2 for imputed TEs (default: 0.5)")
|
|
32
|
+
g.add_argument("--model", choices=("mixed", "ridge", "tag"), default="mixed",
|
|
33
|
+
help="imputation model: best available (default), ridge only, or single tag SNP")
|
|
34
|
+
g.add_argument("--vcf", action="store_true", help="write VCF (GT:DS) instead of TSV")
|
|
35
|
+
g.add_argument("--reference", help="reference FASTA for CRAM")
|
|
36
|
+
g.add_argument("--include-del-contractions", action="store_true",
|
|
37
|
+
help="also report DEL loci that are in-element contractions")
|
|
38
|
+
|
|
39
|
+
sub.add_parser("info", help="show the reference panel summary")
|
|
40
|
+
a = ap.parse_args(argv)
|
|
41
|
+
|
|
42
|
+
if a.cmd == "info":
|
|
43
|
+
print((PANEL_DIR / "PANEL.md").read_text(), end="")
|
|
44
|
+
return
|
|
45
|
+
genotype(a)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def genotype(a):
|
|
49
|
+
jobs = [inspect(path, a.reference) for path in a.input]
|
|
50
|
+
build = pick_build(jobs, a.build)
|
|
51
|
+
for j in jobs:
|
|
52
|
+
j["direct"] = j["kind"] in ("vcf_sv", "bed") or \
|
|
53
|
+
(j["kind"] == "reads" and j["long"] and build == "t2t")
|
|
54
|
+
panel = load_panel(build, models=not all(j["direct"] for j in jobs))
|
|
55
|
+
|
|
56
|
+
results = []
|
|
57
|
+
for j in jobs:
|
|
58
|
+
if len(jobs) > 1:
|
|
59
|
+
log(f"--- {j['path']}")
|
|
60
|
+
results.append(run(j, panel, a))
|
|
61
|
+
samples, calls, clashes, disagree = merge(results)
|
|
62
|
+
shared = [s for s, n in Counter(s for names, _ in results for s in set(names)).items() if n > 1]
|
|
63
|
+
if shared:
|
|
64
|
+
log(f"merged {len(shared):,} sample name(s) found in more than one input "
|
|
65
|
+
f"(e.g. {', '.join(shared[:3])}); rename files that hold different people")
|
|
66
|
+
if clashes:
|
|
67
|
+
log(f"{clashes:,} genotypes called by more than one input ({disagree:,} disagreed); "
|
|
68
|
+
"kept the first")
|
|
69
|
+
|
|
70
|
+
stats = Counter()
|
|
71
|
+
if not a.include_del_contractions:
|
|
72
|
+
keep = [c for c in calls if panel.loci["del_ok"][c[0]]]
|
|
73
|
+
skipped = len(calls) - len(keep)
|
|
74
|
+
calls = keep
|
|
75
|
+
if a.vcf:
|
|
76
|
+
placed = [c for c in calls if panel.te_pos()[c[0]] > 0]
|
|
77
|
+
if len(placed) < len(calls):
|
|
78
|
+
log(f"{len(calls) - len(placed):,} TEs have no {build} position; left out of the VCF")
|
|
79
|
+
calls = placed
|
|
80
|
+
stats.update(m.rstrip("0123456789") for c in calls for m in c[1].split(","))
|
|
81
|
+
|
|
82
|
+
out = open(a.out, "w") if a.out else sys.stdout
|
|
83
|
+
(write_vcf if a.vcf else write_tsv)(out, panel, samples, calls)
|
|
84
|
+
if a.out:
|
|
85
|
+
out.close()
|
|
86
|
+
log(f"{len(calls):,} TEs written for {len(samples):,} sample(s) ("
|
|
87
|
+
+ ", ".join(f"{k} {v:,}" for k, v in stats.most_common()) + ")")
|
|
88
|
+
if not a.include_del_contractions and skipped:
|
|
89
|
+
log(f"{skipped:,} DEL contractions skipped (--include-del-contractions to keep)")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def inspect(path, reference):
|
|
93
|
+
kind, build = sniff(path)
|
|
94
|
+
job = dict(path=path, kind=kind, build=build, long=False)
|
|
95
|
+
if kind == "reads":
|
|
96
|
+
from . import reads
|
|
97
|
+
with reads.open_bam(path, reference) as bam:
|
|
98
|
+
job["samples"], job["rg"], job["build"], job["long"] = reads.describe(bam, path)
|
|
99
|
+
return job
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def pick_build(jobs, forced):
|
|
103
|
+
if forced != "auto":
|
|
104
|
+
clash = [j for j in jobs if j["build"] not in (None, forced)]
|
|
105
|
+
if clash:
|
|
106
|
+
sys.exit(f"--build {forced} conflicts with the header of " + ", ".join(
|
|
107
|
+
f"{j['path']} ({j['build']})" for j in clash))
|
|
108
|
+
log(f"build {forced}")
|
|
109
|
+
return forced
|
|
110
|
+
for j in jobs:
|
|
111
|
+
if j["build"] is None and j["kind"] == "raw":
|
|
112
|
+
j["build"] = "hg19"
|
|
113
|
+
log(f"{j['path']}: no build in header, assuming hg19")
|
|
114
|
+
unknown = [j["path"] for j in jobs if j["build"] is None]
|
|
115
|
+
if unknown:
|
|
116
|
+
sys.exit(f"Unknown genome build for {', '.join(unknown)}; pass --build t2t|hg38|hg19")
|
|
117
|
+
builds = {j["build"] for j in jobs}
|
|
118
|
+
if len(builds) > 1:
|
|
119
|
+
sys.exit("Inputs are on different builds: " + ", ".join(
|
|
120
|
+
f"{j['path']} ({j['build']})" for j in jobs))
|
|
121
|
+
build = builds.pop()
|
|
122
|
+
log(f"build {build}")
|
|
123
|
+
return build
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def run(j, panel, a):
|
|
127
|
+
log(f"input: {j['kind']}")
|
|
128
|
+
kind, path, calls = j["kind"], j["path"], []
|
|
129
|
+
if kind == "bed":
|
|
130
|
+
samples, svs = [sample_name(path)], read_bed(path)
|
|
131
|
+
calls = matched(panel, svs, np.full((len(svs), 1), PRESENT), "bed")
|
|
132
|
+
elif kind in ("vcf_snp", "vcf_sv"):
|
|
133
|
+
samples, G, svs, dose = read_vcf(path, panel)
|
|
134
|
+
if svs or j["direct"]:
|
|
135
|
+
calls = matched(panel, svs, dose, "sv")
|
|
136
|
+
elif kind == "reads":
|
|
137
|
+
from . import reads
|
|
138
|
+
samples = j["samples"]
|
|
139
|
+
with reads.open_bam(path, a.reference) as bam:
|
|
140
|
+
if j["direct"]:
|
|
141
|
+
log("long reads: genotyping from CIGAR indels")
|
|
142
|
+
rows = [i for i in range(len(panel))
|
|
143
|
+
if not reads.in_chry_par(panel.loci["chrom"][i], panel.loci["pos"][i])]
|
|
144
|
+
log(f"{len(panel) - len(rows):,} chrY PAR loci skipped (called on chrX)")
|
|
145
|
+
res = reads.genotype_long(bam, panel, rows, samples, j["rg"])
|
|
146
|
+
calls = [(i, "reads", None, d) for i, d in res.items()]
|
|
147
|
+
else:
|
|
148
|
+
log("pileup at panel SNPs, then imputing")
|
|
149
|
+
G = reads.pileup_sites(bam, panel, samples, j["rg"])
|
|
150
|
+
else:
|
|
151
|
+
samples, G = {"plink": read_plink, "raw": read_raw}[kind](path, panel)
|
|
152
|
+
if not j["direct"]:
|
|
153
|
+
log(f"{len(G):,} of {len(panel.sites):,} panel SNPs found")
|
|
154
|
+
done = {c[0] for c in calls}
|
|
155
|
+
res = impute(panel, G, len(samples), a.min_r2, a.model)
|
|
156
|
+
calls += [(i, *r) for i, r in res.items() if i not in done]
|
|
157
|
+
return samples, calls
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def matched(panel, svs, dose, method):
|
|
161
|
+
pairs = match(panel, svs)
|
|
162
|
+
log(f"{len(pairs):,} of {len(svs):,} input calls matched a catalog TE")
|
|
163
|
+
return [(i, method, None, dose[k]) for k, i in pairs]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def merge(results):
|
|
167
|
+
if len(results) == 1:
|
|
168
|
+
samples, calls = results[0]
|
|
169
|
+
return samples, sorted(calls, key=lambda c: c[0]), 0, 0
|
|
170
|
+
samples = list(dict.fromkeys(s for names, _ in results for s in names))
|
|
171
|
+
col = {s: k for k, s in enumerate(samples)}
|
|
172
|
+
rows, clashes, disagree = {}, 0, 0
|
|
173
|
+
for names, calls in results:
|
|
174
|
+
idx = np.array([col[s] for s in names])
|
|
175
|
+
for i, method, r2, doses in calls:
|
|
176
|
+
methods, r2s, d = rows.setdefault(i, ([], [], np.full(len(samples), np.nan)))
|
|
177
|
+
methods += [m for m in method.split(",") if m not in methods]
|
|
178
|
+
if r2 is not None:
|
|
179
|
+
r2s.append(r2)
|
|
180
|
+
have, new = ~np.isnan(d[idx]), ~np.isnan(doses)
|
|
181
|
+
both = have & new
|
|
182
|
+
clashes += both.sum()
|
|
183
|
+
disagree += (d[idx[both]] != doses[both]).sum()
|
|
184
|
+
d[idx[new & ~have]] = doses[new & ~have]
|
|
185
|
+
calls = [(i, ",".join(m), min(r) if r else None, d) for i, (m, r, d) in sorted(rows.items())]
|
|
186
|
+
return samples, calls, clashes, disagree
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
main()
|
telinker/impute.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from itertools import groupby
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
COMPLEMENT = str.maketrans("ACGT", "TGCA")
|
|
7
|
+
MIN_OBSERVED = 0.8
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def orient(ref, alt, panel_ref, panel_alt):
|
|
11
|
+
for r, a in ((ref, alt), (ref.translate(COMPLEMENT), alt.translate(COMPLEMENT))):
|
|
12
|
+
if (r, a) == (panel_ref, panel_alt):
|
|
13
|
+
return 1
|
|
14
|
+
if (r, a) == (panel_alt, panel_ref):
|
|
15
|
+
return -1
|
|
16
|
+
if is_palindromic(panel_ref, panel_alt):
|
|
17
|
+
return 0
|
|
18
|
+
return 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_palindromic(ref, alt):
|
|
22
|
+
return {ref, alt} in ({"A", "T"}, {"C", "G"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def impute(panel, G, n_samples, min_r2=0.5, kind="mixed"):
|
|
26
|
+
out = {}
|
|
27
|
+
for te, models in groupby(panel.models, key=lambda m: m.te):
|
|
28
|
+
dose, used = np.full(n_samples, np.nan), []
|
|
29
|
+
for m in models:
|
|
30
|
+
if m.cv_r2 < min_r2:
|
|
31
|
+
break
|
|
32
|
+
if kind != "mixed" and (m.name == "ridge") != (kind == "ridge"):
|
|
33
|
+
continue
|
|
34
|
+
ok = [s in G for s in m.sites]
|
|
35
|
+
need = MIN_OBSERVED if m.name == "ridge" else 1.0
|
|
36
|
+
if np.mean(ok) < need:
|
|
37
|
+
continue
|
|
38
|
+
X = np.full((len(m.sites), n_samples), np.nan)
|
|
39
|
+
for j in np.flatnonzero(ok):
|
|
40
|
+
X[j] = G[m.sites[j]]
|
|
41
|
+
fill = np.isnan(dose) & ((~np.isnan(X)).mean(0) >= need)
|
|
42
|
+
if not fill.any():
|
|
43
|
+
continue
|
|
44
|
+
X = np.where(np.isnan(X), m.means[:, None], X)
|
|
45
|
+
dose[fill] = np.clip(m.intercept + m.weights @ X[:, fill], 0, 2)
|
|
46
|
+
used.append(m)
|
|
47
|
+
if not np.isnan(dose).any():
|
|
48
|
+
break
|
|
49
|
+
if used:
|
|
50
|
+
out[te] = (",".join(m.name for m in used), min(m.cv_r2 for m in used), dose)
|
|
51
|
+
return out
|
telinker/inputs.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import gzip
|
|
3
|
+
import io
|
|
4
|
+
import re
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from . import log
|
|
11
|
+
from .impute import COMPLEMENT, is_palindromic, orient
|
|
12
|
+
from .match import catalog_positions, has_near
|
|
13
|
+
from .panel import norm_chrom
|
|
14
|
+
|
|
15
|
+
CHR1_LENGTH = {248387328: "t2t", 248956422: "hg38", 249250621: "hg19"}
|
|
16
|
+
SV_TYPES = re.compile(r"(?:^|;)SVTYPE=([^;]+)")
|
|
17
|
+
SV_LEN = re.compile(r"(?:^|;)SVLEN=(-?\d+)")
|
|
18
|
+
INS_TYPES = re.compile(r"INS|ALU|LINE|L1|SVA|HERV|MEI|ME:", re.I)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sample_name(path):
|
|
22
|
+
return Path(path).name.split(".")[0]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def open_text(path):
|
|
26
|
+
path = Path(path)
|
|
27
|
+
with open(path, "rb") as f:
|
|
28
|
+
magic = f.read(4)
|
|
29
|
+
if magic[:2] == b"\x1f\x8b":
|
|
30
|
+
return gzip.open(path, "rt")
|
|
31
|
+
if magic == b"PK\x03\x04":
|
|
32
|
+
z = zipfile.ZipFile(path)
|
|
33
|
+
return io.TextIOWrapper(z.open(z.namelist()[0]))
|
|
34
|
+
return open(path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def plink_prefix(path):
|
|
38
|
+
s = str(path)
|
|
39
|
+
return s[:-4] if s.lower().endswith((".bed", ".bim", ".fam")) else s
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sniff(path):
|
|
43
|
+
name = Path(path).name.lower()
|
|
44
|
+
if name.endswith((".bam", ".cram")):
|
|
45
|
+
return "reads", None
|
|
46
|
+
if name.endswith(".bcf"):
|
|
47
|
+
raise SystemExit("BCF is not supported; convert with: bcftools view -Oz -o x.vcf.gz x.bcf")
|
|
48
|
+
prefix = plink_prefix(path)
|
|
49
|
+
if Path(prefix + ".bim").exists() and Path(prefix + ".bed").exists():
|
|
50
|
+
return "plink", None
|
|
51
|
+
if not Path(path).exists():
|
|
52
|
+
raise SystemExit(f"No such file: {path}")
|
|
53
|
+
if name.endswith((".bed", ".bed.gz")):
|
|
54
|
+
return "bed", None
|
|
55
|
+
with open_text(path) as f:
|
|
56
|
+
first = next(f, "")
|
|
57
|
+
if first.startswith("##fileformat=VCF"):
|
|
58
|
+
return sniff_vcf(f)
|
|
59
|
+
head = first + "".join(next(f, "") for _ in range(30))
|
|
60
|
+
text = head.lower()
|
|
61
|
+
if "rsid" in text:
|
|
62
|
+
build = "hg19" if re.search(r"build 37|grch37", text) else \
|
|
63
|
+
"hg38" if re.search(r"build 38|grch38", text) else None
|
|
64
|
+
return "raw", build
|
|
65
|
+
raise SystemExit(f"{path}: unrecognized format (expected VCF, PLINK, BED, BAM/CRAM or 23andMe)")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def sniff_vcf(f, n=2000):
|
|
69
|
+
build = None
|
|
70
|
+
for line in f:
|
|
71
|
+
if line.startswith("##contig=") and re.search(r"[<,]ID=(chr)?1[,>]", line):
|
|
72
|
+
m = re.search(r"length=(\d+)", line)
|
|
73
|
+
build = CHR1_LENGTH.get(int(m.group(1))) if m else None
|
|
74
|
+
if line.startswith("#CHROM"):
|
|
75
|
+
break
|
|
76
|
+
snv = sv = 0
|
|
77
|
+
for i, line in enumerate(f):
|
|
78
|
+
ref, alt, _, _, info = line.split("\t", 8)[3:8]
|
|
79
|
+
if len(ref) == 1 and len(alt) == 1 and alt in "ACGT":
|
|
80
|
+
snv += 1
|
|
81
|
+
elif sv_kind(ref, alt, info):
|
|
82
|
+
sv += 1
|
|
83
|
+
if i >= n:
|
|
84
|
+
break
|
|
85
|
+
return ("vcf_sv" if sv > snv else "vcf_snp"), build
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def vcf_records(path):
|
|
89
|
+
with open_text(path) as f:
|
|
90
|
+
for line in f:
|
|
91
|
+
if line.startswith("#CHROM"):
|
|
92
|
+
yield line.rstrip("\n").split("\t")[9:]
|
|
93
|
+
break
|
|
94
|
+
for line in f:
|
|
95
|
+
yield line
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def gt_dosage(samples_fields):
|
|
99
|
+
v = np.array(samples_fields, dtype="S3").view(np.uint8).reshape(-1, 3)
|
|
100
|
+
a, b = v[:, 0], v[:, 2]
|
|
101
|
+
dose = (a == ord("1")).astype(np.float32) + (b == ord("1"))
|
|
102
|
+
dose[(a == ord(".")) | (b == ord(".")) | (v[:, 1] == 0)] = np.nan
|
|
103
|
+
return dose
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def sample_dosage(fields):
|
|
107
|
+
fmt = fields[8].split(":")
|
|
108
|
+
if "DS" in fmt:
|
|
109
|
+
i, k, n = fmt.index("DS"), len(fmt), len(fields) - 9
|
|
110
|
+
values = ":".join(fields[9:]).split(":")
|
|
111
|
+
if len(values) == k * n:
|
|
112
|
+
try:
|
|
113
|
+
return np.array(values[i::k], dtype=np.float32)
|
|
114
|
+
except ValueError:
|
|
115
|
+
pass
|
|
116
|
+
return np.array([_float(s.split(":")[i]) if s.count(":") >= i else np.nan
|
|
117
|
+
for s in fields[9:]], dtype=np.float32)
|
|
118
|
+
if fmt[0] != "GT":
|
|
119
|
+
return np.full(len(fields) - 9, np.nan, dtype=np.float32)
|
|
120
|
+
return gt_dosage(fields[9:])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _float(s):
|
|
124
|
+
try:
|
|
125
|
+
return float(s)
|
|
126
|
+
except ValueError:
|
|
127
|
+
return np.nan
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_vcf(path, panel):
|
|
131
|
+
near = catalog_positions(panel)
|
|
132
|
+
recs = vcf_records(path)
|
|
133
|
+
samples = next(recs)
|
|
134
|
+
G, calls, dose = {}, [], []
|
|
135
|
+
for line in recs:
|
|
136
|
+
chrom, pos, _ = line.split("\t", 2)
|
|
137
|
+
chrom, pos = norm_chrom(chrom), int(pos)
|
|
138
|
+
hits = panel.site_index.get((chrom, pos))
|
|
139
|
+
ix = near.get(chrom)
|
|
140
|
+
sv_near = ix is not None and has_near(ix, pos)
|
|
141
|
+
if not (hits or sv_near):
|
|
142
|
+
continue
|
|
143
|
+
fields = line.rstrip("\n").split("\t")
|
|
144
|
+
ref, alt = fields[3], fields[4]
|
|
145
|
+
if "," in alt:
|
|
146
|
+
continue
|
|
147
|
+
d = None
|
|
148
|
+
for s in hits or ():
|
|
149
|
+
sign = orient(ref, alt, *panel.sites[s][2:])
|
|
150
|
+
if sign:
|
|
151
|
+
d = sample_dosage(fields) if d is None else d
|
|
152
|
+
G[s] = d if sign > 0 else 2 - d
|
|
153
|
+
kind = sv_near and sv_kind(ref, alt, fields[7])
|
|
154
|
+
if kind:
|
|
155
|
+
d = gt_dosage([s.split(":")[0] for s in fields[9:]]) if fields[8].startswith("GT") \
|
|
156
|
+
else np.full(len(samples), np.nan, np.float32)
|
|
157
|
+
calls.append((chrom, pos, kind, sv_length(ref, alt, fields[7])))
|
|
158
|
+
dose.append(d if kind == "INS" else 2 - d)
|
|
159
|
+
return samples, G, calls, np.array(dose, dtype=np.float32).reshape(len(calls), len(samples))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def sv_kind(ref, alt, info):
|
|
163
|
+
m = SV_TYPES.search(info)
|
|
164
|
+
t = m.group(1) if m else (alt.strip("<>") if alt.startswith("<") else "")
|
|
165
|
+
if t.upper().startswith("DEL"):
|
|
166
|
+
return "DEL"
|
|
167
|
+
if INS_TYPES.search(t):
|
|
168
|
+
return "INS"
|
|
169
|
+
if not t and abs(len(alt) - len(ref)) >= 50:
|
|
170
|
+
return "INS" if len(alt) > len(ref) else "DEL"
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def sv_length(ref, alt, info):
|
|
175
|
+
m = SV_LEN.search(info)
|
|
176
|
+
if m and m.group(1) != "0":
|
|
177
|
+
return abs(int(m.group(1)))
|
|
178
|
+
if not alt.startswith("<"):
|
|
179
|
+
return abs(len(alt) - len(ref))
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def read_bed(path):
|
|
184
|
+
calls = []
|
|
185
|
+
with open_text(path) as f:
|
|
186
|
+
for line in f:
|
|
187
|
+
if line.startswith(("#", "track", "browser")) or not line.strip():
|
|
188
|
+
continue
|
|
189
|
+
c, s, e = line.split("\t")[:3]
|
|
190
|
+
s, e = int(s), int(e)
|
|
191
|
+
calls.append((norm_chrom(c), s + 1, "INS", e - s if e - s >= 50 else None))
|
|
192
|
+
return calls
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def read_plink(path, panel):
|
|
196
|
+
prefix = plink_prefix(path)
|
|
197
|
+
samples = [l.split()[1] for l in open(prefix + ".fam")]
|
|
198
|
+
n = len(samples)
|
|
199
|
+
bpr = (n + 3) // 4
|
|
200
|
+
bed = np.memmap(prefix + ".bed", dtype=np.uint8, mode="r")
|
|
201
|
+
if bed[:3].tolist() != [0x6C, 0x1B, 0x01]:
|
|
202
|
+
raise SystemExit(f"{prefix}.bed: not a SNP-major PLINK 1 file")
|
|
203
|
+
a1_count = np.array([2, np.nan, 1, 0], dtype=np.float32)
|
|
204
|
+
G, strand = {}, Strand()
|
|
205
|
+
with open(prefix + ".bim") as f:
|
|
206
|
+
for i, line in enumerate(f):
|
|
207
|
+
chrom, _, _, pos, a1, a2 = line.split()
|
|
208
|
+
hits = panel.site_index.get((norm_chrom(chrom), int(pos)))
|
|
209
|
+
if not hits:
|
|
210
|
+
continue
|
|
211
|
+
raw = bed[3 + i * bpr: 3 + (i + 1) * bpr]
|
|
212
|
+
codes = ((raw[:, None] >> np.array([0, 2, 4, 6], np.uint8)) & 3).ravel()[:n]
|
|
213
|
+
dose = a1_count[codes]
|
|
214
|
+
for s in hits:
|
|
215
|
+
sign = orient(a2, a1, *panel.sites[s][2:])
|
|
216
|
+
if sign:
|
|
217
|
+
G[s] = dose if sign > 0 else 2 - dose
|
|
218
|
+
strand.add(s, {a1, a2}, panel)
|
|
219
|
+
strand.drop_ambiguous(G, path)
|
|
220
|
+
return samples, G
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def read_raw(path, panel):
|
|
224
|
+
G, strand = {}, Strand()
|
|
225
|
+
with open_text(path) as f:
|
|
226
|
+
for line in f:
|
|
227
|
+
if line.startswith("#") or line.lower().startswith("rsid"):
|
|
228
|
+
continue
|
|
229
|
+
parts = line.replace(",", "\t").split()
|
|
230
|
+
if len(parts) < 4:
|
|
231
|
+
continue
|
|
232
|
+
hits = panel.site_index.get((norm_chrom(parts[1]), int(parts[2])))
|
|
233
|
+
if not hits:
|
|
234
|
+
continue
|
|
235
|
+
gt = "".join(parts[3:5]).upper()
|
|
236
|
+
for s in hits:
|
|
237
|
+
ref, alt = panel.sites[s][2:]
|
|
238
|
+
d = genotype_count(gt, ref, alt)
|
|
239
|
+
if d == d:
|
|
240
|
+
G[s] = np.array([d], dtype=np.float32)
|
|
241
|
+
strand.add(s, set(gt), panel)
|
|
242
|
+
strand.drop_ambiguous(G, path)
|
|
243
|
+
return [sample_name(path)], G
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def genotype_count(gt, ref, alt):
|
|
247
|
+
if len(gt) != 2 or not set(gt) <= set("ACGT"):
|
|
248
|
+
return np.nan
|
|
249
|
+
if set(gt) <= {ref, alt}:
|
|
250
|
+
return float(gt.count(alt))
|
|
251
|
+
if {ref, alt} not in ({"A", "T"}, {"C", "G"}):
|
|
252
|
+
flipped = gt.translate(COMPLEMENT)
|
|
253
|
+
if set(flipped) <= {ref, alt}:
|
|
254
|
+
return float(flipped.count(alt))
|
|
255
|
+
return np.nan
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class Strand:
|
|
259
|
+
def __init__(self):
|
|
260
|
+
self.matched, self.flipped, self.palindromic = 0, 0, []
|
|
261
|
+
|
|
262
|
+
def add(self, site, alleles, panel):
|
|
263
|
+
ref, alt = panel.sites[site][2:]
|
|
264
|
+
self.matched += 1
|
|
265
|
+
if is_palindromic(ref, alt):
|
|
266
|
+
self.palindromic.append(site)
|
|
267
|
+
elif not alleles <= {ref, alt}:
|
|
268
|
+
self.flipped += 1
|
|
269
|
+
|
|
270
|
+
def drop_ambiguous(self, G, path):
|
|
271
|
+
if self.flipped <= 0.01 * self.matched:
|
|
272
|
+
return
|
|
273
|
+
for s in self.palindromic:
|
|
274
|
+
G.pop(s, None)
|
|
275
|
+
log(f"{path}: not strand-aligned, dropped {len(self.palindromic):,} A/T and C/G SNPs")
|
telinker/match.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
POS_TOL = 100
|
|
5
|
+
LEN_LO, LEN_HI = 0.80, 1.25
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def catalog_positions(panel):
|
|
9
|
+
pos = panel.te_pos()
|
|
10
|
+
out = {}
|
|
11
|
+
for i in np.flatnonzero(pos > 0):
|
|
12
|
+
out.setdefault(panel.loci["chrom"][i], []).append(pos[i])
|
|
13
|
+
return {c: np.sort(p) for c, p in out.items()}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def has_near(sorted_pos, p):
|
|
17
|
+
k = np.searchsorted(sorted_pos, p - POS_TOL)
|
|
18
|
+
return k < len(sorted_pos) and sorted_pos[k] <= p + POS_TOL
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def match(panel, calls):
|
|
22
|
+
pos = panel.te_pos()
|
|
23
|
+
by_chrom = {}
|
|
24
|
+
for i in np.flatnonzero(pos > 0):
|
|
25
|
+
by_chrom.setdefault(panel.loci["chrom"][i], []).append(i)
|
|
26
|
+
by_chrom = {c: np.array(sorted(ix, key=lambda i: pos[i])) for c, ix in by_chrom.items()}
|
|
27
|
+
|
|
28
|
+
cands = []
|
|
29
|
+
same_ref = panel.build == "t2t"
|
|
30
|
+
for k, (chrom, p, kind, length) in enumerate(calls):
|
|
31
|
+
ix = by_chrom.get(chrom)
|
|
32
|
+
if ix is None:
|
|
33
|
+
continue
|
|
34
|
+
lo, hi = np.searchsorted(pos[ix], [p - POS_TOL, p + POS_TOL + 1])
|
|
35
|
+
for i in ix[lo:hi]:
|
|
36
|
+
if same_ref and kind and kind != panel.loci["type"][i]:
|
|
37
|
+
continue
|
|
38
|
+
ratio = length / panel.loci["len"][i] if length else 1.0
|
|
39
|
+
if LEN_LO <= ratio <= LEN_HI:
|
|
40
|
+
cands.append((abs(np.log(ratio)), abs(int(pos[i]) - p), k, i))
|
|
41
|
+
used_call, used_te, out = set(), set(), []
|
|
42
|
+
for _, _, k, i in sorted(cands):
|
|
43
|
+
if k not in used_call and i not in used_te:
|
|
44
|
+
used_call.add(k)
|
|
45
|
+
used_te.add(i)
|
|
46
|
+
out.append((k, i))
|
|
47
|
+
return out
|
telinker/output.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
COLUMNS = ["te_id", "chrom", "pos", "type", "subfamily", "method", "r2"]
|
|
5
|
+
PRESENT = -1.0
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fmt(d):
|
|
9
|
+
if d != d:
|
|
10
|
+
return "."
|
|
11
|
+
if d == PRESENT:
|
|
12
|
+
return "present"
|
|
13
|
+
return str(int(d)) if float(d).is_integer() else f"{d:.2f}"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def write_tsv(out, panel, samples, calls):
|
|
17
|
+
pos = panel.te_pos()
|
|
18
|
+
print("\t".join(COLUMNS + list(samples)), file=out)
|
|
19
|
+
for i, method, r2, doses in calls:
|
|
20
|
+
L = panel.loci
|
|
21
|
+
row = [L["te_id"][i], L["chrom"][i], pos[i] if pos[i] > 0 else ".", L["type"][i],
|
|
22
|
+
L["subfamily"][i],
|
|
23
|
+
method, "" if r2 is None else f"{r2:.2f}"]
|
|
24
|
+
print("\t".join(map(str, row + list(map(fmt, doses.tolist())))), file=out)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write_vcf(out, panel, samples, calls):
|
|
28
|
+
pos = panel.te_pos()
|
|
29
|
+
print("##fileformat=VCFv4.2", file=out)
|
|
30
|
+
print(f"##source=telinker ({' '.join(sys.argv[1:])})", file=out)
|
|
31
|
+
print(f"##reference={panel.build}", file=out)
|
|
32
|
+
print('##ALT=<ID=TE,Description="Polymorphic TE present (TE-presence allele)">', file=out)
|
|
33
|
+
print('##INFO=<ID=SUBFAM,Number=1,Type=String,Description="TE subfamily">', file=out)
|
|
34
|
+
print('##INFO=<ID=CATTYPE,Number=1,Type=String,Description="Catalog type vs T2T-CHM13 (INS/DEL)">', file=out)
|
|
35
|
+
print('##INFO=<ID=METHOD,Number=1,Type=String,Description="ridge, tag1-10, sv, bed or reads">', file=out)
|
|
36
|
+
print('##INFO=<ID=R2,Number=1,Type=Float,Description="Panel out-of-fold r2 (imputed calls)">', file=out)
|
|
37
|
+
print('##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype">', file=out)
|
|
38
|
+
print('##FORMAT=<ID=DS,Number=1,Type=Float,Description="TE-presence dosage">', file=out)
|
|
39
|
+
print("\t".join(["#CHROM", "POS", "ID", "REF", "ALT", "QUAL", "FILTER", "INFO", "FORMAT"]
|
|
40
|
+
+ list(samples)), file=out)
|
|
41
|
+
rows = sorted(calls, key=lambda c: (chrom_key(panel.loci["chrom"][c[0]]), pos[c[0]]))
|
|
42
|
+
for i, method, r2, doses in rows:
|
|
43
|
+
L = panel.loci
|
|
44
|
+
info = f"SUBFAM={L['subfamily'][i]};CATTYPE={L['type'][i]};METHOD={method}"
|
|
45
|
+
if r2 is not None:
|
|
46
|
+
info += f";R2={r2:.3f}"
|
|
47
|
+
gts = []
|
|
48
|
+
for d in doses.tolist():
|
|
49
|
+
if d != d or d == PRESENT:
|
|
50
|
+
gts.append("1/.:." if d == PRESENT else "./.:.")
|
|
51
|
+
else:
|
|
52
|
+
g = int(round(d))
|
|
53
|
+
gts.append(f"{['0/0', '0/1', '1/1'][g]}:{d:.2f}")
|
|
54
|
+
print("\t".join(map(str, ["chr" + L["chrom"][i], pos[i], L["te_id"][i], "N", "<TE>",
|
|
55
|
+
".", "PASS", info, "GT:DS"] + gts)), file=out)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def chrom_key(c):
|
|
59
|
+
return (0, int(c)) if c.isdigit() else (1, c)
|
telinker/panel/PANEL.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# teLinker reference panel
|
|
2
|
+
|
|
3
|
+
Built 2026-10-21 by `dev/build_panel.py` from 213 HPRC individuals.
|
|
4
|
+
|
|
5
|
+
- Catalog loci: 30,257 (17,260 INS, 12,997 DEL; 7,002 DELs fail the contraction gate `del_gate_r50`)
|
|
6
|
+
- TEs with at least one SNP model (cv r² ≥ 0.2): 21,749
|
|
7
|
+
- best cv r² ≥ 0.5: 18,783; ≥ 0.8: 14,647
|
|
8
|
+
- Ridge vs best single tag, same TEs (n=19,291): median cv r² 0.914 vs 0.904; ridge better on 40.2%
|
|
9
|
+
- Median best cv r² by TE MAF: <1% 0.48, 1–5% 0.91, 5–20% 0.96, ≥20% 0.97
|
|
10
|
+
- Median cv r² within AFR samples (best model): 0.891 (tags and windows learned mostly on non-AFR haplotypes)
|
|
11
|
+
- hg38 lift of top tags agrees with the 1.6.1 tag table on 99.32%
|
|
12
|
+
|
|
13
|
+
cv r² is out-of-fold with SNP selection repeated inside each fold.
|
|
14
|
+
Dosages are TE-PRESENCE (0-2) for both INS and DEL loci.
|
|
Binary file
|
|
Binary file
|
telinker/panel.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import csv
|
|
3
|
+
import gzip
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from itertools import groupby
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
PANEL_DIR = Path(os.environ.get("TELINKER_PANEL", Path(__file__).parent / "panel"))
|
|
12
|
+
BUILDS = ("t2t", "hg38", "hg19")
|
|
13
|
+
POS_COL = {"t2t": "pos", "hg38": "pos_hg38", "hg19": "pos_hg19"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def norm_chrom(c: str) -> str:
|
|
17
|
+
c = c[3:] if c.lower().startswith("chr") else c
|
|
18
|
+
return "M" if c == "MT" else c
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_tsv(path):
|
|
22
|
+
with gzip.open(path, "rt") as f:
|
|
23
|
+
return list(csv.DictReader(f, delimiter="\t"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Model:
|
|
28
|
+
te: int
|
|
29
|
+
name: str
|
|
30
|
+
cv_r2: float
|
|
31
|
+
intercept: float
|
|
32
|
+
sites: np.ndarray
|
|
33
|
+
weights: np.ndarray
|
|
34
|
+
means: np.ndarray
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Panel:
|
|
39
|
+
build: str
|
|
40
|
+
loci: dict
|
|
41
|
+
sites: list = field(default_factory=list)
|
|
42
|
+
site_index: dict = field(default_factory=dict)
|
|
43
|
+
models: list = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
def __len__(self):
|
|
46
|
+
return len(self.loci["te_id"])
|
|
47
|
+
|
|
48
|
+
def te_pos(self):
|
|
49
|
+
return self.loci[POS_COL[self.build]]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_panel(build: str, panel_dir: Path = PANEL_DIR, models: bool = True) -> Panel:
|
|
53
|
+
rows = read_tsv(panel_dir / "loci.tsv.gz")
|
|
54
|
+
loci = {k: np.array([r[k] for r in rows]) for k in rows[0]}
|
|
55
|
+
for k in ("pos", "len", "pos_hg38", "pos_hg19"):
|
|
56
|
+
loci[k] = np.array([int(v) if v else -1 for v in loci[k]])
|
|
57
|
+
loci["del_ok"] = loci["del_ok"] == "1"
|
|
58
|
+
loci["chrom"] = np.array([norm_chrom(c) for c in loci["chrom"]])
|
|
59
|
+
panel = Panel(build, loci)
|
|
60
|
+
if models:
|
|
61
|
+
load_models(panel, panel_dir / "models.tsv.gz")
|
|
62
|
+
return panel
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_models(panel, path):
|
|
66
|
+
te_row = {t: i for i, t in enumerate(panel.loci["te_id"])}
|
|
67
|
+
site_row = {}
|
|
68
|
+
with gzip.open(path, "rt") as f:
|
|
69
|
+
r = csv.reader(f, delimiter="\t")
|
|
70
|
+
col = {k: i for i, k in enumerate(next(r))}
|
|
71
|
+
te, name, pos = col["te_id"], col["model"], col[POS_COL[panel.build]]
|
|
72
|
+
for (te_id, model), terms in groupby(r, key=lambda t: (t[te], t[name])):
|
|
73
|
+
terms = list(terms)
|
|
74
|
+
sites = []
|
|
75
|
+
for t in terms:
|
|
76
|
+
if not t[pos]:
|
|
77
|
+
sites.append(-1)
|
|
78
|
+
continue
|
|
79
|
+
snp = (norm_chrom(t[col["chrom"]]), int(t[pos]), t[col["ref"]], t[col["alt"]])
|
|
80
|
+
if snp not in site_row:
|
|
81
|
+
site_row[snp] = len(panel.sites)
|
|
82
|
+
panel.site_index.setdefault(snp[:2], []).append(len(panel.sites))
|
|
83
|
+
panel.sites.append(snp)
|
|
84
|
+
sites.append(site_row[snp])
|
|
85
|
+
panel.models.append(Model(
|
|
86
|
+
te_row[te_id], model, float(terms[0][col["cv_r2"]]),
|
|
87
|
+
float(terms[0][col["intercept"]]), np.array(sites),
|
|
88
|
+
np.array([float(t[col["weight"]]) for t in terms]),
|
|
89
|
+
np.array([float(t[col["mean"]]) for t in terms])))
|
|
90
|
+
panel.models.sort(key=lambda m: (m.te, -m.cv_r2))
|
telinker/reads.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from .inputs import CHR1_LENGTH, sample_name
|
|
6
|
+
from .panel import norm_chrom
|
|
7
|
+
|
|
8
|
+
FLANK = 200
|
|
9
|
+
WIN = 300
|
|
10
|
+
ANCHOR = 1000
|
|
11
|
+
FRAC = 0.5
|
|
12
|
+
MIN_DEPTH = 10
|
|
13
|
+
MIN_SNP_DEPTH = 5
|
|
14
|
+
MIN_MAPQ = 20
|
|
15
|
+
BLOCK = 100_000
|
|
16
|
+
BASE = {b: i for i, b in enumerate("ACGT")}
|
|
17
|
+
CHRY_PAR = ((0, 2_458_320), (62_122_809, 62_460_029))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def open_bam(path, reference=None):
|
|
21
|
+
try:
|
|
22
|
+
import pysam
|
|
23
|
+
except ImportError:
|
|
24
|
+
raise SystemExit("BAM/CRAM input needs pysam: pip install 'telinker[reads]'")
|
|
25
|
+
return pysam.AlignmentFile(str(path), reference_filename=reference)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def describe(bam, path):
|
|
29
|
+
header = bam.header.to_dict()
|
|
30
|
+
rg = {r["ID"]: r["SM"] for r in header.get("RG", []) if r.get("SM")}
|
|
31
|
+
names = list(dict.fromkeys(rg.values()))
|
|
32
|
+
samples, rg = (names, rg) if len(names) > 1 else ([sample_name(path)], {})
|
|
33
|
+
build = None
|
|
34
|
+
for sq in header.get("SQ", []):
|
|
35
|
+
if norm_chrom(sq["SN"]) == "1":
|
|
36
|
+
build = CHR1_LENGTH.get(sq["LN"])
|
|
37
|
+
lengths = []
|
|
38
|
+
for rd in bam.fetch(until_eof=True):
|
|
39
|
+
if not (rd.is_secondary or rd.is_supplementary or rd.is_unmapped):
|
|
40
|
+
lengths.append(rd.query_length)
|
|
41
|
+
if len(lengths) >= 2000:
|
|
42
|
+
break
|
|
43
|
+
return samples, rg, build, bool(lengths) and float(np.median(lengths)) > 2000
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def contig_names(bam):
|
|
47
|
+
return {norm_chrom(c): c for c in bam.references}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def usable(rd):
|
|
51
|
+
return not (rd.is_secondary or rd.is_supplementary or rd.is_unmapped)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sample_of(rd, rg, col):
|
|
55
|
+
if not rg:
|
|
56
|
+
return 0
|
|
57
|
+
return col.get(rg.get(rd.get_tag("RG"))) if rd.has_tag("RG") else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def in_chry_par(chrom, pos):
|
|
61
|
+
return chrom == "Y" and any(lo <= pos < hi for lo, hi in CHRY_PAR)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_evidence(cigar, start, pos, length, is_ins):
|
|
65
|
+
end = pos if is_ins else pos + length
|
|
66
|
+
left = (pos - WIN - ANCHOR, pos - WIN)
|
|
67
|
+
right = (end + WIN, end + WIN + ANCHOR)
|
|
68
|
+
need = FRAC * length
|
|
69
|
+
r, left_bp, right_bp, alt, other = start, 0, 0, False, False
|
|
70
|
+
for op, n in cigar:
|
|
71
|
+
if op == 1 or op == 2:
|
|
72
|
+
if abs(r - pos) <= WIN:
|
|
73
|
+
if n >= need and (op == 1) == is_ins:
|
|
74
|
+
alt = True
|
|
75
|
+
elif n >= max(50, need):
|
|
76
|
+
other = True
|
|
77
|
+
r += n if op == 2 else 0
|
|
78
|
+
elif op == 3:
|
|
79
|
+
r += n
|
|
80
|
+
elif op in (0, 7, 8):
|
|
81
|
+
left_bp += max(0, min(r + n, left[1]) - max(r, left[0]))
|
|
82
|
+
right_bp += max(0, min(r + n, right[1]) - max(r, right[0]))
|
|
83
|
+
r += n
|
|
84
|
+
if left_bp < FLANK or right_bp < FLANK:
|
|
85
|
+
return None
|
|
86
|
+
return "alt" if alt else "other" if other else "ref"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def call(n_alt, n_ref):
|
|
90
|
+
n = n_alt + n_ref
|
|
91
|
+
if n < MIN_DEPTH:
|
|
92
|
+
return None
|
|
93
|
+
f = n_alt / n
|
|
94
|
+
if f >= 0.90:
|
|
95
|
+
return 2
|
|
96
|
+
if f <= 0.05:
|
|
97
|
+
return 0
|
|
98
|
+
if 0.20 <= f <= 0.80:
|
|
99
|
+
return 1
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def genotype_long(bam, panel, rows, samples, rg):
|
|
104
|
+
names = contig_names(bam)
|
|
105
|
+
col = {s: k for k, s in enumerate(samples)}
|
|
106
|
+
out = {}
|
|
107
|
+
for i in rows:
|
|
108
|
+
chrom = names.get(panel.loci["chrom"][i])
|
|
109
|
+
if chrom is None:
|
|
110
|
+
continue
|
|
111
|
+
pos, length = int(panel.loci["pos"][i]), int(panel.loci["len"][i])
|
|
112
|
+
is_ins = panel.loci["type"][i] == "INS"
|
|
113
|
+
n_alt, n_ref = [0] * len(samples), [0] * len(samples)
|
|
114
|
+
for rd in bam.fetch(chrom, max(0, pos - 3000), pos + (0 if is_ins else length) + 3000):
|
|
115
|
+
k = sample_of(rd, rg, col) if usable(rd) and rd.cigartuples else None
|
|
116
|
+
if k is None:
|
|
117
|
+
continue
|
|
118
|
+
ev = read_evidence(rd.cigartuples, rd.reference_start, pos, length, is_ins)
|
|
119
|
+
n_alt[k] += ev == "alt"
|
|
120
|
+
n_ref[k] += ev == "ref"
|
|
121
|
+
gs = [call(a, r) for a, r in zip(n_alt, n_ref)]
|
|
122
|
+
if any(g is not None for g in gs):
|
|
123
|
+
out[i] = np.array([np.nan if g is None else g if is_ins else 2 - g
|
|
124
|
+
for g in gs])
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def pileup_sites(bam, panel, samples, rg):
|
|
129
|
+
names = contig_names(bam)
|
|
130
|
+
col = {s: k for k, s in enumerate(samples)}
|
|
131
|
+
filters = [lambda rd, k=k: usable(rd) and not (rd.is_duplicate or rd.is_qcfail)
|
|
132
|
+
and rd.mapping_quality >= MIN_MAPQ and sample_of(rd, rg, col) == k
|
|
133
|
+
for k in range(len(samples))]
|
|
134
|
+
by_chrom = {}
|
|
135
|
+
for s, (chrom, pos, _, _) in enumerate(panel.sites):
|
|
136
|
+
if chrom in names:
|
|
137
|
+
by_chrom.setdefault(chrom, []).append((pos, s))
|
|
138
|
+
|
|
139
|
+
G = {}
|
|
140
|
+
for chrom, sites in by_chrom.items():
|
|
141
|
+
sites.sort()
|
|
142
|
+
i = 0
|
|
143
|
+
while i < len(sites):
|
|
144
|
+
start = sites[i][0] - 1
|
|
145
|
+
j = i
|
|
146
|
+
while j < len(sites) and sites[j][0] <= start + BLOCK:
|
|
147
|
+
j += 1
|
|
148
|
+
block, end = sites[i:j], sites[j - 1][0]
|
|
149
|
+
g = np.full((len(block), len(samples)), np.nan, dtype=np.float32)
|
|
150
|
+
for k, keep in enumerate(filters):
|
|
151
|
+
counts = np.array(bam.count_coverage(names[chrom], start, end, quality_threshold=20,
|
|
152
|
+
read_callback=keep))
|
|
153
|
+
for b, (pos, s) in enumerate(block):
|
|
154
|
+
ref, alt = panel.sites[s][2:]
|
|
155
|
+
n_ref, n_alt = counts[BASE[ref], pos - 1 - start], counts[BASE[alt], pos - 1 - start]
|
|
156
|
+
if n_ref + n_alt >= MIN_SNP_DEPTH:
|
|
157
|
+
f = n_alt / (n_ref + n_alt)
|
|
158
|
+
g[b, k] = 0 if f < 0.15 else 2 if f > 0.85 else 1
|
|
159
|
+
for b, (_, s) in enumerate(block):
|
|
160
|
+
if not np.isnan(g[b]).all():
|
|
161
|
+
G[s] = g[b]
|
|
162
|
+
i = j
|
|
163
|
+
return G
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: telinker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Genotype polymorphic transposable elements from VCF, PLINK, array, BED or BAM/CRAM files
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: numpy>=1.20
|
|
10
|
+
Provides-Extra: reads
|
|
11
|
+
Requires-Dist: pysam>=0.19; extra == "reads"
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest; extra == "test"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# teLinker
|
|
17
|
+
|
|
18
|
+
Genotype polymorphic transposable elements in your own data.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
pip install telinker # numpy is the only dependency
|
|
22
|
+
pip install 'telinker[reads]' # + pysam, only for BAM/CRAM input
|
|
23
|
+
|
|
24
|
+
telinker genotype cohort.vcf.gz -o tes.tsv
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Report bugs
|
|
28
|
+
|
|
29
|
+
https://github.com/jaleesr/teLinker
|
|
30
|
+
|
|
31
|
+
## Inputs
|
|
32
|
+
|
|
33
|
+
The input type and genome build are detected automatically. Pass `--build t2t|hg38|hg19`
|
|
34
|
+
if the build information is not present in the file header.
|
|
35
|
+
|
|
36
|
+
| Input | How TEs are called | `method` |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| SNP VCF (WGS, or imputed with `DS`) | Imputed from nearby SNPs | `ridge`, `tagN` |
|
|
39
|
+
| PLINK `.bed/.bim/.fam` | Imputed | `ridge`, `tagN` |
|
|
40
|
+
| 23andMe / AncestryDNA raw file (`.txt` or `.zip`) | Imputed (mostly single tags: arrays are sparse) | `tagN`, `ridge` |
|
|
41
|
+
| SV VCF (Sniffles, SVAN, MELT, PAV, …) | Matched to the catalog | `sv` |
|
|
42
|
+
| BED of TE insertion calls | Matched; presence only | `bed` |
|
|
43
|
+
| Long-read BAM/CRAM on T2T-CHM13 | Read directly from indels in the alignments | `reads` |
|
|
44
|
+
| Short-read BAM/CRAM | SNPs piled up, then imputed | `ridge`, `tagN` |
|
|
45
|
+
|
|
46
|
+
BCF is not read directly; convert it with `bcftools view -Oz`.
|
|
47
|
+
|
|
48
|
+
### Several samples
|
|
49
|
+
|
|
50
|
+
**Multi-sample files.** VCF and PLINK inputs give one column per sample. A BAM/CRAM whose
|
|
51
|
+
read groups carry several `SM` tags is split by read group into one column per `SM`
|
|
52
|
+
|
|
53
|
+
**Several files.** These are merged into one table:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
telinker genotype donor1.bam donor2.bam donor3.bam -o cohort.tsv
|
|
57
|
+
telinker genotype cohort.chr*.vcf.gz -o cohort.tsv # per-chromosome splits
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- Every input must be on the same build.
|
|
61
|
+
- Sample names are matched across files.
|
|
62
|
+
- If two files genotype the same TE in the same sample, the first file wins.
|
|
63
|
+
- Single-sample files (a one-sample BAM, a 23andMe file, a BED) are named after the file,
|
|
64
|
+
up to the first dot. So `donor1.sorted.bam` becomes `donor1`.
|
|
65
|
+
- If the same sample name comes from more than one file, telinker merges them and warns. That
|
|
66
|
+
is right for a cohort split by chromosome. If the files are different people rename them.
|
|
67
|
+
|
|
68
|
+
In long-read mode, chrY pseudoautosomal loci are skipped. Their reads are shared with chrX,
|
|
69
|
+
so a chrY call would just repeat the chrX genotype.
|
|
70
|
+
|
|
71
|
+
## Output
|
|
72
|
+
|
|
73
|
+
One TSV. Each row is a TE, and each sample column is a **TE-presence dosage** (0–2):
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
te_id chrom pos type subfamily method r2 S1 S2
|
|
77
|
+
10_102771350_INS_306_0 10 103646104 INS AluYa5 tag1 1.00 0 1
|
|
78
|
+
10_100722374_INS_226_0 10 . INS SVA_F tag1 0.70 0.92 1.78
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- **Dosage cells:** dosage always counts copies of the TE, for both INS and DEL loci.
|
|
82
|
+
Imputed calls are continuous. `.` means the TE could not be called in that sample.
|
|
83
|
+
- **`r2`:** the panel's held-out accuracy for that TE's model. It is empty for direct calls.
|
|
84
|
+
- **`pos`:** the position in your input's build. `.` means the locus does not lift to that build.
|
|
85
|
+
- **`--vcf`:** writes the same calls as a VCF with `GT:DS`, ready for plink2, regenie
|
|
86
|
+
or tensorQTL. Its ALT allele is `<TE>` (TE present).
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
## Layout
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
telinker/ the installed package (numpy only; pysam imported lazily for BAM/CRAM)
|
|
93
|
+
cli.py argument parsing, input detection, dispatch, summary log
|
|
94
|
+
inputs.py format sniffing + readers: VCF (SNPs and SVs), PLINK, 23andMe/Ancestry, BED
|
|
95
|
+
impute.py allele matching and the linear-model prediction / fallback
|
|
96
|
+
match.py SV and BED calls -> catalog loci
|
|
97
|
+
reads.py BAM/CRAM: long-read CIGAR genotyper, short-read SNP pileup
|
|
98
|
+
output.py TSV and VCF writers
|
|
99
|
+
panel.py loads telinker/panel/ (loci.tsv.gz, models.tsv.gz, PANEL.md)
|
|
100
|
+
tests/ pytest suite on a hand-built four-TE panel
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Tests
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
pip install '.[test,reads]'
|
|
107
|
+
pytest tests
|
|
108
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
telinker/__init__.py,sha256=Rurx7kofeNw1aaIhfCXiO0rrrzBYy3lxoodkpL0pHDI,98
|
|
2
|
+
telinker/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
telinker/cli.py,sha256=csgbYBPa5TM_Ep2qP_vKUED2m0e-LHbOHF8_zTl4G_8,8104
|
|
4
|
+
telinker/impute.py,sha256=0HgZZMVc1jivWmRXcmI6Rv_t6xZcSfr86iSK_kjiGJ4,1725
|
|
5
|
+
telinker/inputs.py,sha256=wl8RCN93cISKvQZwJk_e9WRcdwauv5c-KDHpNO3_hJ4,9179
|
|
6
|
+
telinker/match.py,sha256=inETMnXRbe2qveVXS9dRGkjy1KDdroZb5SzUQvFw554,1542
|
|
7
|
+
telinker/output.py,sha256=NsP92QoKVjr66Bb6LUVcWtjjsJ21eoCksVd0WGVB3H8,2630
|
|
8
|
+
telinker/panel.py,sha256=0kqMCPoBziuhiW4UEuPP26Lq6Zb2YG_FW8_HqL13Pk0,2952
|
|
9
|
+
telinker/reads.py,sha256=PuF38tQBp5rtssO1gjZKn3fQxsMIkHzy-yTgmA02ffI,5586
|
|
10
|
+
telinker/panel/PANEL.md,sha256=qtC7aVkMstvUWCJ0yeN5Q7sx6BjFqLcplBp7skIjZDI,792
|
|
11
|
+
telinker/panel/loci.tsv.gz,sha256=mca5raf-w2sMHRfb2RgQJpfy-m48l3RyWkfi3zKj4Dk,724295
|
|
12
|
+
telinker/panel/models.tsv.gz,sha256=8L462PnIQphs2s13rf-VpuOAmf8jatVHlYaeEKMuuOk,23664759
|
|
13
|
+
telinker-0.1.0.dist-info/licenses/LICENSE,sha256=o5sgzIYQ4mOG2B74mfzd8q-qq9AgSdYT1TIGtHwKVUM,1134
|
|
14
|
+
telinker-0.1.0.dist-info/METADATA,sha256=e-jj0mpy05Ek40Iy86JIGfP51meW9zKfqXkdKm6gIz0,4231
|
|
15
|
+
telinker-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
16
|
+
telinker-0.1.0.dist-info/entry_points.txt,sha256=Pppwl8-u4gXhy1Z_o6yXC7CxHQX7VDi0JotDArofqsQ,47
|
|
17
|
+
telinker-0.1.0.dist-info/top_level.txt,sha256=qDQ3iES0tUcK7g3awDycyDkadN4PqaNV-WxmHY2lKOw,9
|
|
18
|
+
telinker-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Sanborn, Jalees Rehman, Board of Trustees of the University of Illinois.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
telinker
|