telinker 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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
+ recursive-include tests *.py
@@ -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,93 @@
1
+ # teLinker
2
+
3
+ Genotype polymorphic transposable elements in your own data.
4
+
5
+ ```
6
+ pip install telinker # numpy is the only dependency
7
+ pip install 'telinker[reads]' # + pysam, only for BAM/CRAM input
8
+
9
+ telinker genotype cohort.vcf.gz -o tes.tsv
10
+ ```
11
+
12
+ ## Report bugs
13
+
14
+ https://github.com/jaleesr/teLinker
15
+
16
+ ## Inputs
17
+
18
+ The input type and genome build are detected automatically. Pass `--build t2t|hg38|hg19`
19
+ if the build information is not present in the file header.
20
+
21
+ | Input | How TEs are called | `method` |
22
+ |---|---|---|
23
+ | SNP VCF (WGS, or imputed with `DS`) | Imputed from nearby SNPs | `ridge`, `tagN` |
24
+ | PLINK `.bed/.bim/.fam` | Imputed | `ridge`, `tagN` |
25
+ | 23andMe / AncestryDNA raw file (`.txt` or `.zip`) | Imputed (mostly single tags: arrays are sparse) | `tagN`, `ridge` |
26
+ | SV VCF (Sniffles, SVAN, MELT, PAV, …) | Matched to the catalog | `sv` |
27
+ | BED of TE insertion calls | Matched; presence only | `bed` |
28
+ | Long-read BAM/CRAM on T2T-CHM13 | Read directly from indels in the alignments | `reads` |
29
+ | Short-read BAM/CRAM | SNPs piled up, then imputed | `ridge`, `tagN` |
30
+
31
+ BCF is not read directly; convert it with `bcftools view -Oz`.
32
+
33
+ ### Several samples
34
+
35
+ **Multi-sample files.** VCF and PLINK inputs give one column per sample. A BAM/CRAM whose
36
+ read groups carry several `SM` tags is split by read group into one column per `SM`
37
+
38
+ **Several files.** These are merged into one table:
39
+
40
+ ```
41
+ telinker genotype donor1.bam donor2.bam donor3.bam -o cohort.tsv
42
+ telinker genotype cohort.chr*.vcf.gz -o cohort.tsv # per-chromosome splits
43
+ ```
44
+
45
+ - Every input must be on the same build.
46
+ - Sample names are matched across files.
47
+ - If two files genotype the same TE in the same sample, the first file wins.
48
+ - Single-sample files (a one-sample BAM, a 23andMe file, a BED) are named after the file,
49
+ up to the first dot. So `donor1.sorted.bam` becomes `donor1`.
50
+ - If the same sample name comes from more than one file, telinker merges them and warns. That
51
+ is right for a cohort split by chromosome. If the files are different people rename them.
52
+
53
+ In long-read mode, chrY pseudoautosomal loci are skipped. Their reads are shared with chrX,
54
+ so a chrY call would just repeat the chrX genotype.
55
+
56
+ ## Output
57
+
58
+ One TSV. Each row is a TE, and each sample column is a **TE-presence dosage** (0–2):
59
+
60
+ ```
61
+ te_id chrom pos type subfamily method r2 S1 S2
62
+ 10_102771350_INS_306_0 10 103646104 INS AluYa5 tag1 1.00 0 1
63
+ 10_100722374_INS_226_0 10 . INS SVA_F tag1 0.70 0.92 1.78
64
+ ```
65
+
66
+ - **Dosage cells:** dosage always counts copies of the TE, for both INS and DEL loci.
67
+ Imputed calls are continuous. `.` means the TE could not be called in that sample.
68
+ - **`r2`:** the panel's held-out accuracy for that TE's model. It is empty for direct calls.
69
+ - **`pos`:** the position in your input's build. `.` means the locus does not lift to that build.
70
+ - **`--vcf`:** writes the same calls as a VCF with `GT:DS`, ready for plink2, regenie
71
+ or tensorQTL. Its ALT allele is `<TE>` (TE present).
72
+
73
+
74
+ ## Layout
75
+
76
+ ```
77
+ telinker/ the installed package (numpy only; pysam imported lazily for BAM/CRAM)
78
+ cli.py argument parsing, input detection, dispatch, summary log
79
+ inputs.py format sniffing + readers: VCF (SNPs and SVs), PLINK, 23andMe/Ancestry, BED
80
+ impute.py allele matching and the linear-model prediction / fallback
81
+ match.py SV and BED calls -> catalog loci
82
+ reads.py BAM/CRAM: long-read CIGAR genotyper, short-read SNP pileup
83
+ output.py TSV and VCF writers
84
+ panel.py loads telinker/panel/ (loci.tsv.gz, models.tsv.gz, PANEL.md)
85
+ tests/ pytest suite on a hand-built four-TE panel
86
+ ```
87
+
88
+ ## Tests
89
+
90
+ ```
91
+ pip install '.[test,reads]'
92
+ pytest tests
93
+ ```
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "telinker"
7
+ version = "0.1.0"
8
+ description = "Genotype polymorphic transposable elements from VCF, PLINK, array, BED or BAM/CRAM files"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = ["numpy>=1.20"]
14
+
15
+ [project.optional-dependencies]
16
+ reads = ["pysam>=0.19"]
17
+ test = ["pytest"]
18
+
19
+ [project.scripts]
20
+ telinker = "telinker.cli:main"
21
+
22
+ [tool.setuptools]
23
+ packages = ["telinker", "telinker.panel"]
24
+
25
+ [tool.setuptools.package-data]
26
+ "telinker.panel" = ["*.gz", "*.md"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ import sys
2
+
3
+ __version__ = "0.1.0"
4
+
5
+
6
+ def log(msg):
7
+ print(f"[telinker] {msg}", file=sys.stderr)
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -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()
@@ -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