phasepred 0.9.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.
- phasepred/__init__.py +3 -0
- phasepred/cli.py +1053 -0
- phasepred/data/models/PdPS/8f_model_0.joblib +0 -0
- phasepred/data/models/PdPS/8f_model_1.joblib +0 -0
- phasepred/data/models/PdPS/8f_model_2.joblib +0 -0
- phasepred/data/models/PdPS/8f_model_3.joblib +0 -0
- phasepred/data/models/PdPS/8f_model_4.joblib +0 -0
- phasepred/data/models/PdPS/8f_model_5.joblib +0 -0
- phasepred/data/models/PdPS/8f_model_6.joblib +0 -0
- phasepred/data/models/PdPS/8f_model_7.joblib +0 -0
- phasepred/data/models/PdPS/8f_model_8.joblib +0 -0
- phasepred/data/models/PdPS/8f_model_9.joblib +0 -0
- phasepred/data/models/PdPS/manifest.json +25 -0
- phasepred/data/models/SaPS/8f_model_0.joblib +0 -0
- phasepred/data/models/SaPS/8f_model_1.joblib +0 -0
- phasepred/data/models/SaPS/8f_model_2.joblib +0 -0
- phasepred/data/models/SaPS/8f_model_3.joblib +0 -0
- phasepred/data/models/SaPS/8f_model_4.joblib +0 -0
- phasepred/data/models/SaPS/8f_model_5.joblib +0 -0
- phasepred/data/models/SaPS/8f_model_6.joblib +0 -0
- phasepred/data/models/SaPS/8f_model_7.joblib +0 -0
- phasepred/data/models/SaPS/8f_model_8.joblib +0 -0
- phasepred/data/models/SaPS/8f_model_9.joblib +0 -0
- phasepred/data/models/SaPS/manifest.json +25 -0
- phasepred/data/models/hPdPS/8f_model_0.joblib +0 -0
- phasepred/data/models/hPdPS/8f_model_1.joblib +0 -0
- phasepred/data/models/hPdPS/8f_model_2.joblib +0 -0
- phasepred/data/models/hPdPS/8f_model_3.joblib +0 -0
- phasepred/data/models/hPdPS/8f_model_4.joblib +0 -0
- phasepred/data/models/hPdPS/8f_model_5.joblib +0 -0
- phasepred/data/models/hPdPS/8f_model_6.joblib +0 -0
- phasepred/data/models/hPdPS/8f_model_7.joblib +0 -0
- phasepred/data/models/hPdPS/8f_model_8.joblib +0 -0
- phasepred/data/models/hPdPS/8f_model_9.joblib +0 -0
- phasepred/data/models/hPdPS/manifest.json +27 -0
- phasepred/data/models/hSaPS/8f_model_0.joblib +0 -0
- phasepred/data/models/hSaPS/8f_model_1.joblib +0 -0
- phasepred/data/models/hSaPS/8f_model_2.joblib +0 -0
- phasepred/data/models/hSaPS/8f_model_3.joblib +0 -0
- phasepred/data/models/hSaPS/8f_model_4.joblib +0 -0
- phasepred/data/models/hSaPS/8f_model_5.joblib +0 -0
- phasepred/data/models/hSaPS/8f_model_6.joblib +0 -0
- phasepred/data/models/hSaPS/8f_model_7.joblib +0 -0
- phasepred/data/models/hSaPS/8f_model_8.joblib +0 -0
- phasepred/data/models/hSaPS/8f_model_9.joblib +0 -0
- phasepred/data/models/hSaPS/manifest.json +27 -0
- phasepred/data.py +65 -0
- phasepred/espritz.py +192 -0
- phasepred/features.py +158 -0
- phasepred/legacy_features.py +224 -0
- phasepred/models.py +141 -0
- phasepred/paper.py +160 -0
- phasepred/predictor.py +215 -0
- phasepred/sequences.py +220 -0
- phasepred/tool_features.py +218 -0
- phasepred/tool_paths.py +429 -0
- phasepred/tools.py +602 -0
- phasepred/updated_data.py +349 -0
- phasepred-0.9.0.dist-info/METADATA +283 -0
- phasepred-0.9.0.dist-info/RECORD +64 -0
- phasepred-0.9.0.dist-info/WHEEL +5 -0
- phasepred-0.9.0.dist-info/entry_points.txt +2 -0
- phasepred-0.9.0.dist-info/licenses/LICENSE +35 -0
- phasepred-0.9.0.dist-info/top_level.txt +1 -0
phasepred/tools.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""External feature-tool runners.
|
|
2
|
+
|
|
3
|
+
Module boundary (S2): ``features`` holds pure computation, while this
|
|
4
|
+
module owns the *four-step* lifecycle for every external feature tool —
|
|
5
|
+
**locate** (resolve the tool-package entry), **execute** (subprocess or
|
|
6
|
+
lookup), **parse** (translate raw output into per-accession values), and
|
|
7
|
+
**degrade** (return ``{}`` so the caller stamps NaN).
|
|
8
|
+
|
|
9
|
+
Since S3 the *locate* step goes through the manifest registry in
|
|
10
|
+
``phasepred.tool_paths``: each tool is a self-contained package under
|
|
11
|
+
``tools/<TOOL>/`` (``run`` entrypoint + ``manifest.toml`` contract) and the
|
|
12
|
+
registry resolves ``PHASEPRED_<TOOL>_DIR`` → ``tools/<TOOL>/`` → PATH.
|
|
13
|
+
The package ``run`` scripts dispatch exactly the same underlying commands
|
|
14
|
+
as the pre-S3 wrappers, so output values are unchanged.
|
|
15
|
+
|
|
16
|
+
The universal public surface is ``run_<tool>(records) -> dict[str, float]``
|
|
17
|
+
with ``records`` a list of ``{"accession": str, "sequence": str}`` dicts.
|
|
18
|
+
A missing/broken tool degrades to ``{}`` — never raises at inference time
|
|
19
|
+
(path resolution problems still raise ``PhaSePredToolNotFound`` so
|
|
20
|
+
``predict``'s preflight can fail fast).
|
|
21
|
+
|
|
22
|
+
Per-feature v2022 definitions (``docs/FEATURES.md``):
|
|
23
|
+
- DeepCoil: binarized ``1.0 if max(raw_cc) >= 0.82 else 0.0``.
|
|
24
|
+
- ESpritz: DisProt model ``D``, ``sw 0`` (5% FPR), IDR = fraction of ``D``
|
|
25
|
+
state residues parsed from the two-column residue lines only.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
import tempfile
|
|
34
|
+
import warnings
|
|
35
|
+
from multiprocessing import Pool
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
import pandas as pd
|
|
39
|
+
|
|
40
|
+
from phasepred.data import data_path
|
|
41
|
+
from phasepred.tool_paths import (
|
|
42
|
+
find_tool_entry,
|
|
43
|
+
find_tool_entry_or_none,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PhaSePredMissingDataWarning(UserWarning):
|
|
48
|
+
"""Emitted when optional data (DeepPhase/PhosphoSitePlus) is missing.
|
|
49
|
+
|
|
50
|
+
The lookup runners are the only emitters; ``predictor`` re-exports it so
|
|
51
|
+
``predict`` can register it with the ``warnings`` machinery.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
DEEPHASE_TABLE = data_path("data", "raw", "external", "deepphase", "extracted", "tableS3.xlsx")
|
|
56
|
+
PHOSPHOSITE_PATH = data_path(
|
|
57
|
+
"data", "raw", "external", "phosphositeplus", "Phosphorylation_site_dataset.gz"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
ESPRITZ_STATE_RE = re.compile(r"^(?P<state>[DO])\s+(?P<score>\S+)$")
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# ESpritz (IDR)
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _safe_stem(accession: str) -> str:
|
|
68
|
+
return "".join(c if c.isalnum() or c in "-_" else "_" for c in accession)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_espritz(
|
|
72
|
+
records: list[dict[str, str]],
|
|
73
|
+
*,
|
|
74
|
+
model: str = "D",
|
|
75
|
+
sw: int = 0,
|
|
76
|
+
) -> dict[str, float]:
|
|
77
|
+
"""Run ESpritz DisProt 5% FPR on all records (batch in one directory).
|
|
78
|
+
|
|
79
|
+
Returns the per-accession IDR fraction (``count(D) / L``) computed from
|
|
80
|
+
the two-column residue lines only — banner/license lines are ignored.
|
|
81
|
+
"""
|
|
82
|
+
wrapper = find_tool_entry("espritz")
|
|
83
|
+
workdir = Path(tempfile.mkdtemp(prefix="espritz_pred_"))
|
|
84
|
+
try:
|
|
85
|
+
for r in records:
|
|
86
|
+
fasta = workdir / f"{_safe_stem(r['accession'])}.fasta"
|
|
87
|
+
fasta.write_text(
|
|
88
|
+
f">{r['accession']}\n{str(r['sequence']).upper()}\n", encoding="utf-8"
|
|
89
|
+
)
|
|
90
|
+
result = subprocess.run(
|
|
91
|
+
[str(wrapper), str(workdir), model, str(sw)],
|
|
92
|
+
capture_output=True, text=True, timeout=300,
|
|
93
|
+
)
|
|
94
|
+
if result.returncode != 0:
|
|
95
|
+
return {}
|
|
96
|
+
|
|
97
|
+
scores: dict[str, float] = {}
|
|
98
|
+
for r in records:
|
|
99
|
+
out = workdir / f"{_safe_stem(r['accession'])}.espritz"
|
|
100
|
+
if not out.exists():
|
|
101
|
+
continue
|
|
102
|
+
states = _parse_espritz_states(out)
|
|
103
|
+
if states:
|
|
104
|
+
scores[r["accession"]] = sum(s == "D" for s in states) / len(states)
|
|
105
|
+
return scores
|
|
106
|
+
finally:
|
|
107
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_espritz_states(out_path: Path) -> list[str]:
|
|
111
|
+
"""Extract ordered residue states from a ``.espritz`` output file.
|
|
112
|
+
|
|
113
|
+
Only two-column residue lines whose first token is D or O are counted;
|
|
114
|
+
license/banner/sequence lines are skipped (v2022 IDR parsing rule).
|
|
115
|
+
"""
|
|
116
|
+
states: list[str] = []
|
|
117
|
+
for line in out_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
118
|
+
match = ESPRITZ_STATE_RE.match(line.strip())
|
|
119
|
+
if match is not None:
|
|
120
|
+
states.append(match.group("state"))
|
|
121
|
+
return states
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# SEG (LCR)
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_seg(records: list[dict[str, str]]) -> dict[str, float]:
|
|
130
|
+
"""Run SEG with the default parameters (``12 2.2 2.5 -x``)."""
|
|
131
|
+
seg_bin = find_tool_entry("seg")
|
|
132
|
+
|
|
133
|
+
fasta_text = "".join(
|
|
134
|
+
f">{r['accession']}\n{str(r['sequence']).upper()}\n" for r in records
|
|
135
|
+
)
|
|
136
|
+
with tempfile.NamedTemporaryFile(
|
|
137
|
+
mode="w", suffix=".fasta", delete=False, prefix="seg_pred_"
|
|
138
|
+
) as fh:
|
|
139
|
+
fh.write(fasta_text)
|
|
140
|
+
fasta_path = Path(fh.name)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
result = subprocess.run(
|
|
144
|
+
[str(seg_bin), str(fasta_path), "12", "2.2", "2.5", "-x"],
|
|
145
|
+
capture_output=True, text=True, timeout=120,
|
|
146
|
+
)
|
|
147
|
+
if result.returncode != 0:
|
|
148
|
+
return {}
|
|
149
|
+
|
|
150
|
+
masked: dict[str, str] = {}
|
|
151
|
+
current_id = ""
|
|
152
|
+
current_seq: list[str] = []
|
|
153
|
+
for line in result.stdout.split("\n"):
|
|
154
|
+
line = line.strip()
|
|
155
|
+
if line.startswith(">"):
|
|
156
|
+
if current_id:
|
|
157
|
+
masked[current_id] = "".join(current_seq)
|
|
158
|
+
current_id = line[1:].split()[0]
|
|
159
|
+
current_seq = []
|
|
160
|
+
elif line:
|
|
161
|
+
current_seq.append(line)
|
|
162
|
+
if current_id:
|
|
163
|
+
masked[current_id] = "".join(current_seq)
|
|
164
|
+
|
|
165
|
+
scores: dict[str, float] = {}
|
|
166
|
+
for r in records:
|
|
167
|
+
seq = masked.get(r["accession"], "")
|
|
168
|
+
if seq:
|
|
169
|
+
lc = sum(1 for c in seq if c == "x" or c.islower())
|
|
170
|
+
scores[r["accession"]] = lc / len(seq)
|
|
171
|
+
return scores
|
|
172
|
+
finally:
|
|
173
|
+
fasta_path.unlink(missing_ok=True)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# PScore
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _pscore_chunk(args: tuple[int, list[dict[str, str]]]) -> tuple[int, str]:
|
|
182
|
+
chunk_idx, recs = args
|
|
183
|
+
fasta_text = "".join(
|
|
184
|
+
f">{r['accession']}\n{str(r['sequence']).upper()}\n" for r in recs
|
|
185
|
+
)
|
|
186
|
+
with tempfile.TemporaryDirectory(prefix=f"pscore_pred_{chunk_idx}_") as tmp:
|
|
187
|
+
workdir = Path(tmp)
|
|
188
|
+
fasta = workdir / "input.fasta"
|
|
189
|
+
output = workdir / "pscore.tsv"
|
|
190
|
+
fasta.write_text(fasta_text, encoding="utf-8")
|
|
191
|
+
subprocess.run(
|
|
192
|
+
[str(find_tool_entry("pscore")),
|
|
193
|
+
str(fasta), "-output", str(output), "-overwrite", "-mute"],
|
|
194
|
+
check=True, capture_output=True, text=True, timeout=300,
|
|
195
|
+
)
|
|
196
|
+
return (chunk_idx, output.read_text(encoding="utf-8"))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run_pscore(
|
|
200
|
+
records: list[dict[str, str]],
|
|
201
|
+
*,
|
|
202
|
+
n_workers: int = 8,
|
|
203
|
+
) -> dict[str, float]:
|
|
204
|
+
"""Run PScore in parallel chunks; single values from ``PScore:`` lines."""
|
|
205
|
+
find_tool_entry("pscore")
|
|
206
|
+
|
|
207
|
+
chunk_size = max(1, len(records) // n_workers)
|
|
208
|
+
chunks: list[tuple[int, list[dict[str, str]]]] = []
|
|
209
|
+
for i in range(0, len(records), chunk_size):
|
|
210
|
+
chunks.append((len(chunks), records[i : i + chunk_size]))
|
|
211
|
+
|
|
212
|
+
scores: dict[str, float] = {}
|
|
213
|
+
with Pool(processes=min(n_workers, len(chunks))) as pool:
|
|
214
|
+
for _, output in pool.imap_unordered(_pscore_chunk, chunks):
|
|
215
|
+
for line in output.strip().split("\n"):
|
|
216
|
+
if not line.startswith("PScore:"):
|
|
217
|
+
continue
|
|
218
|
+
parts = line.strip().split()
|
|
219
|
+
if len(parts) >= 2:
|
|
220
|
+
try:
|
|
221
|
+
value = float(parts[1])
|
|
222
|
+
acc = parts[-1].lstrip(">")
|
|
223
|
+
scores[acc] = value
|
|
224
|
+
except (ValueError, IndexError):
|
|
225
|
+
continue
|
|
226
|
+
return scores
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# PLAAC (NLLR)
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _plaac_chunk(args: tuple[int, list[dict[str, str]]]) -> tuple[int, str]:
|
|
235
|
+
chunk_idx, recs = args
|
|
236
|
+
fasta_text = "".join(
|
|
237
|
+
f">{r['accession']}\n{str(r['sequence']).upper()}\n" for r in recs
|
|
238
|
+
)
|
|
239
|
+
with tempfile.TemporaryDirectory(prefix=f"plaac_pred_{chunk_idx}_") as tmp:
|
|
240
|
+
fasta = Path(tmp) / "input.fasta"
|
|
241
|
+
fasta.write_text(fasta_text, encoding="utf-8")
|
|
242
|
+
result = subprocess.run(
|
|
243
|
+
[str(find_tool_entry("plaac")), "-i", str(fasta)],
|
|
244
|
+
cwd=str(data_path()),
|
|
245
|
+
check=True, capture_output=True, text=True, timeout=300,
|
|
246
|
+
)
|
|
247
|
+
return (chunk_idx, result.stdout)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def run_plaac(
|
|
251
|
+
records: list[dict[str, str]],
|
|
252
|
+
*,
|
|
253
|
+
n_workers: int = 4,
|
|
254
|
+
) -> dict[str, float]:
|
|
255
|
+
"""Run PLAAC in parallel chunks; take the ``NLLR`` column value."""
|
|
256
|
+
find_tool_entry("plaac")
|
|
257
|
+
|
|
258
|
+
chunk_size = max(1, len(records) // n_workers)
|
|
259
|
+
chunks: list[tuple[int, list[dict[str, str]]]] = []
|
|
260
|
+
for i in range(0, len(records), chunk_size):
|
|
261
|
+
chunks.append((len(chunks), records[i : i + chunk_size]))
|
|
262
|
+
|
|
263
|
+
scores: dict[str, float] = {}
|
|
264
|
+
with Pool(processes=min(n_workers, len(chunks))) as pool:
|
|
265
|
+
for _, output in pool.imap_unordered(_plaac_chunk, chunks):
|
|
266
|
+
header: list[str] | None = None
|
|
267
|
+
for line in output.strip().split("\n"):
|
|
268
|
+
if line.startswith("#") or not line.strip():
|
|
269
|
+
continue
|
|
270
|
+
parts = line.rstrip("\n").split("\t")
|
|
271
|
+
if header is None:
|
|
272
|
+
if parts and parts[0] == "SEQid":
|
|
273
|
+
header = parts
|
|
274
|
+
continue
|
|
275
|
+
if len(parts) != len(header):
|
|
276
|
+
continue
|
|
277
|
+
record = dict(zip(header, parts, strict=False))
|
|
278
|
+
try:
|
|
279
|
+
scores[str(record["SEQid"])] = float(record["NLLR"])
|
|
280
|
+
except (KeyError, ValueError):
|
|
281
|
+
continue
|
|
282
|
+
return scores
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
# DeepCoil (v2022 binary definition)
|
|
287
|
+
# ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _safe_id(accession: str) -> str:
|
|
291
|
+
"""DeepCoil sanitizes output filenames; mirror its rule."""
|
|
292
|
+
return "".join(ch for ch in str(accession) if ch.isalnum() or ch == "_")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
DEEPCOIL_THRESHOLD = 0.82
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _binarize_deepcoil_frame(frame: pd.DataFrame) -> float:
|
|
299
|
+
"""v2022 DeepCoil feature: ``1.0 if max(raw_cc) >= 0.82 else 0.0``."""
|
|
300
|
+
if frame.empty:
|
|
301
|
+
raise ValueError("empty DeepCoil output")
|
|
302
|
+
return float(1.0 if frame["raw_cc"].max() >= DEEPCOIL_THRESHOLD else 0.0)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def run_deepcoil(records: list[dict[str, str]]) -> dict[str, float]:
|
|
306
|
+
"""Run DeepCoil on demand for the input sequences.
|
|
307
|
+
|
|
308
|
+
Aggregates the per-residue ``raw_cc`` array into the v2022 *binary*
|
|
309
|
+
coiled-coil indicator (threshold 0.82). Returns ``{}`` on any failure
|
|
310
|
+
(wrapper not installed, env broken, subprocess timeout) so the caller
|
|
311
|
+
degrades the column to NaN.
|
|
312
|
+
"""
|
|
313
|
+
if not records:
|
|
314
|
+
return {}
|
|
315
|
+
|
|
316
|
+
runner = find_tool_entry_or_none("deepcoil")
|
|
317
|
+
if runner is None:
|
|
318
|
+
return {} # contract package missing → degrade to NaN
|
|
319
|
+
|
|
320
|
+
scores: dict[str, float] = {}
|
|
321
|
+
with tempfile.TemporaryDirectory(prefix="deepcoil_predict_") as tmp:
|
|
322
|
+
workdir = Path(tmp)
|
|
323
|
+
fasta = workdir / "input.fasta"
|
|
324
|
+
out_dir = workdir / "out"
|
|
325
|
+
out_dir.mkdir()
|
|
326
|
+
|
|
327
|
+
lines: list[str] = []
|
|
328
|
+
id_map: dict[str, str] = {} # safe_id → original accession
|
|
329
|
+
for r in records:
|
|
330
|
+
acc = str(r["accession"])
|
|
331
|
+
safe = _safe_id(acc)
|
|
332
|
+
id_map[safe] = acc
|
|
333
|
+
seq = re.sub(r"\s+", "", str(r["sequence"]).upper())
|
|
334
|
+
lines.append(f">{safe}")
|
|
335
|
+
lines.extend(seq[i : i + 80] for i in range(0, len(seq), 80))
|
|
336
|
+
fasta.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
subprocess.run(
|
|
340
|
+
[
|
|
341
|
+
str(runner),
|
|
342
|
+
"-i", str(fasta),
|
|
343
|
+
"-out_path", str(out_dir),
|
|
344
|
+
"-n_cpu", "4",
|
|
345
|
+
],
|
|
346
|
+
check=True,
|
|
347
|
+
capture_output=True,
|
|
348
|
+
text=True,
|
|
349
|
+
timeout=max(300, 30 * len(records)),
|
|
350
|
+
)
|
|
351
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
352
|
+
return {}
|
|
353
|
+
|
|
354
|
+
for safe, orig in id_map.items():
|
|
355
|
+
out_file = out_dir / f"{safe}.out"
|
|
356
|
+
if not out_file.exists():
|
|
357
|
+
continue
|
|
358
|
+
try:
|
|
359
|
+
frame = pd.read_csv(out_file, sep="\t")
|
|
360
|
+
scores[orig] = _binarize_deepcoil_frame(frame)
|
|
361
|
+
except Exception:
|
|
362
|
+
continue
|
|
363
|
+
return scores
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
# Residue-level extractors (tool-features subcommand)
|
|
368
|
+
#
|
|
369
|
+
# These mirror the *per-residue* arrays of the web ``tools.<x>`` payloads for
|
|
370
|
+
# the tools whose local contracts expose them. Single values stay on the
|
|
371
|
+
# ``run_<tool>`` surface; here we return dict[accession, dict] with the raw
|
|
372
|
+
# residue lists so the CLI can derive both the summary and the JSON.
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _parse_espritz_residues(out_path: Path) -> tuple[list[str], list[float]]:
|
|
377
|
+
states: list[str] = []
|
|
378
|
+
scores: list[float] = []
|
|
379
|
+
for line in out_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
380
|
+
match = ESPRITZ_STATE_RE.match(line.strip())
|
|
381
|
+
if match is not None:
|
|
382
|
+
states.append(match.group("state"))
|
|
383
|
+
try:
|
|
384
|
+
scores.append(float(match.group("score")))
|
|
385
|
+
except ValueError:
|
|
386
|
+
scores.append(float("nan"))
|
|
387
|
+
return states, scores
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def espritz_residues(
|
|
391
|
+
records: list[dict[str, str]],
|
|
392
|
+
*,
|
|
393
|
+
model: str = "D",
|
|
394
|
+
sw: int = 0,
|
|
395
|
+
) -> dict[str, dict[str, object]]:
|
|
396
|
+
"""Per-residue ESpritz ``label`` (D/O) + ``residue`` (score) arrays."""
|
|
397
|
+
wrapper = find_tool_entry("espritz")
|
|
398
|
+
workdir = Path(tempfile.mkdtemp(prefix="espritz_res_"))
|
|
399
|
+
try:
|
|
400
|
+
for r in records:
|
|
401
|
+
fasta = workdir / f"{_safe_stem(r['accession'])}.fasta"
|
|
402
|
+
fasta.write_text(
|
|
403
|
+
f">{r['accession']}\n{str(r['sequence']).upper()}\n", encoding="utf-8"
|
|
404
|
+
)
|
|
405
|
+
result = subprocess.run(
|
|
406
|
+
[str(wrapper), str(workdir), model, str(sw)],
|
|
407
|
+
capture_output=True, text=True, timeout=300,
|
|
408
|
+
)
|
|
409
|
+
if result.returncode != 0:
|
|
410
|
+
return {}
|
|
411
|
+
out: dict[str, dict[str, object]] = {}
|
|
412
|
+
for r in records:
|
|
413
|
+
path = workdir / f"{_safe_stem(r['accession'])}.espritz"
|
|
414
|
+
if not path.exists():
|
|
415
|
+
continue
|
|
416
|
+
states, scores = _parse_espritz_residues(path)
|
|
417
|
+
out[r["accession"]] = {"label": states, "residue": scores}
|
|
418
|
+
return out
|
|
419
|
+
finally:
|
|
420
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _seg_masked_map(seg_bin: Path, fasta_path: Path) -> dict[str, str]:
|
|
424
|
+
result = subprocess.run(
|
|
425
|
+
[str(seg_bin), str(fasta_path), "12", "2.2", "2.5", "-x"],
|
|
426
|
+
capture_output=True, text=True, timeout=120,
|
|
427
|
+
)
|
|
428
|
+
if result.returncode != 0:
|
|
429
|
+
return {}
|
|
430
|
+
masked: dict[str, str] = {}
|
|
431
|
+
current_id = ""
|
|
432
|
+
current_seq: list[str] = []
|
|
433
|
+
for line in result.stdout.split("\n"):
|
|
434
|
+
line = line.strip()
|
|
435
|
+
if line.startswith(">"):
|
|
436
|
+
if current_id:
|
|
437
|
+
masked[current_id] = "".join(current_seq)
|
|
438
|
+
current_id = line[1:].split()[0]
|
|
439
|
+
current_seq = []
|
|
440
|
+
elif line:
|
|
441
|
+
current_seq.append(line)
|
|
442
|
+
if current_id:
|
|
443
|
+
masked[current_id] = "".join(current_seq)
|
|
444
|
+
return masked
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def seg_residues(records: list[dict[str, str]]) -> dict[str, dict[str, object]]:
|
|
448
|
+
"""Per-residue SEG low-complexity ``label`` mask (1 = masked)."""
|
|
449
|
+
seg_bin = find_tool_entry("seg")
|
|
450
|
+
fasta_text = "".join(
|
|
451
|
+
f">{r['accession']}\n{str(r['sequence']).upper()}\n" for r in records
|
|
452
|
+
)
|
|
453
|
+
with tempfile.NamedTemporaryFile(
|
|
454
|
+
mode="w", suffix=".fasta", delete=False, prefix="seg_res_"
|
|
455
|
+
) as fh:
|
|
456
|
+
fh.write(fasta_text)
|
|
457
|
+
fasta_path = Path(fh.name)
|
|
458
|
+
try:
|
|
459
|
+
masked = _seg_masked_map(seg_bin, fasta_path)
|
|
460
|
+
out: dict[str, dict[str, object]] = {}
|
|
461
|
+
for r in records:
|
|
462
|
+
seq = masked.get(r["accession"], "")
|
|
463
|
+
label = [1.0 if c == "x" or c.islower() else 0.0 for c in seq]
|
|
464
|
+
out[r["accession"]] = {"label": label}
|
|
465
|
+
return out
|
|
466
|
+
finally:
|
|
467
|
+
fasta_path.unlink(missing_ok=True)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def deepcoil_residues(records: list[dict[str, str]]) -> dict[str, dict[str, object]]:
|
|
471
|
+
"""Per-residue DeepCoil ``coiled-coil`` (raw_cc) + ``sharpen`` (cc) arrays."""
|
|
472
|
+
if not records:
|
|
473
|
+
return {}
|
|
474
|
+
runner = find_tool_entry_or_none("deepcoil")
|
|
475
|
+
if runner is None:
|
|
476
|
+
return {}
|
|
477
|
+
out: dict[str, dict[str, object]] = {}
|
|
478
|
+
with tempfile.TemporaryDirectory(prefix="deepcoil_res_") as tmp:
|
|
479
|
+
workdir = Path(tmp)
|
|
480
|
+
fasta = workdir / "input.fasta"
|
|
481
|
+
out_dir = workdir / "out"
|
|
482
|
+
out_dir.mkdir()
|
|
483
|
+
lines: list[str] = []
|
|
484
|
+
id_map: dict[str, str] = {}
|
|
485
|
+
for r in records:
|
|
486
|
+
acc = str(r["accession"])
|
|
487
|
+
safe = _safe_id(acc)
|
|
488
|
+
id_map[safe] = acc
|
|
489
|
+
seq = re.sub(r"\s+", "", str(r["sequence"]).upper())
|
|
490
|
+
lines.append(f">{safe}")
|
|
491
|
+
lines.extend(seq[i : i + 80] for i in range(0, len(seq), 80))
|
|
492
|
+
fasta.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
493
|
+
try:
|
|
494
|
+
subprocess.run(
|
|
495
|
+
[str(runner), "-i", str(fasta), "-out_path", str(out_dir), "-n_cpu", "4"],
|
|
496
|
+
check=True, capture_output=True, text=True,
|
|
497
|
+
timeout=max(300, 30 * len(records)),
|
|
498
|
+
)
|
|
499
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
500
|
+
return {}
|
|
501
|
+
for safe, orig in id_map.items():
|
|
502
|
+
fpath = out_dir / f"{safe}.out"
|
|
503
|
+
if not fpath.exists():
|
|
504
|
+
continue
|
|
505
|
+
try:
|
|
506
|
+
frame = pd.read_csv(fpath, sep="\t")
|
|
507
|
+
out[orig] = {
|
|
508
|
+
"coiled-coil": frame["raw_cc"].astype(float).tolist(),
|
|
509
|
+
"sharpen": frame["cc"].astype(float).tolist(),
|
|
510
|
+
}
|
|
511
|
+
except Exception:
|
|
512
|
+
continue
|
|
513
|
+
return out
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# ---------------------------------------------------------------------------
|
|
517
|
+
# Lookups (DeepPhase table, PhosphoSitePlus)
|
|
518
|
+
# ---------------------------------------------------------------------------
|
|
519
|
+
def lookup_deepphase(accessions: list[str]) -> dict[str, float]:
|
|
520
|
+
"""Look up DeepPhase scores from the DeepPhase supplement table S3.
|
|
521
|
+
|
|
522
|
+
DeepPhase has no inference tool — only the reference table. Accessions
|
|
523
|
+
not in the table return nothing (the caller imputes with a warning).
|
|
524
|
+
"""
|
|
525
|
+
if not DEEPHASE_TABLE.exists():
|
|
526
|
+
warnings.warn(
|
|
527
|
+
(
|
|
528
|
+
f"DeepPhase table missing at {DEEPHASE_TABLE}. "
|
|
529
|
+
"Human-mode predictions (hSaPS / hPdPS) will use median "
|
|
530
|
+
"imputation for the DeepPhase column. See the README "
|
|
531
|
+
"section 'Optional human-feature data' for the download."
|
|
532
|
+
),
|
|
533
|
+
PhaSePredMissingDataWarning,
|
|
534
|
+
stacklevel=2,
|
|
535
|
+
)
|
|
536
|
+
return {}
|
|
537
|
+
dp = pd.read_excel(str(DEEPHASE_TABLE), sheet_name="tableS3")
|
|
538
|
+
dp_map: dict[str, float] = {}
|
|
539
|
+
for _, row in dp.iterrows():
|
|
540
|
+
swiss = str(row.get("Swiss-Prot ID", "")).strip()
|
|
541
|
+
if swiss and swiss != "nan":
|
|
542
|
+
try:
|
|
543
|
+
dp_map[swiss] = float(row["DeepPhase_score"])
|
|
544
|
+
except (ValueError, KeyError):
|
|
545
|
+
continue
|
|
546
|
+
hits = {a: dp_map[a] for a in accessions if a in dp_map}
|
|
547
|
+
missing = [a for a in accessions if a not in dp_map]
|
|
548
|
+
if missing:
|
|
549
|
+
preview = ", ".join(missing[:5]) + ("…" if len(missing) > 5 else "")
|
|
550
|
+
warnings.warn(
|
|
551
|
+
(
|
|
552
|
+
f"DeepPhase has no entry for {len(missing)}/{len(accessions)} "
|
|
553
|
+
f"requested protein(s): {preview}. Median imputation will be "
|
|
554
|
+
"used, so hSaPS/hPdPS scores for these proteins should be "
|
|
555
|
+
"treated as approximate."
|
|
556
|
+
),
|
|
557
|
+
PhaSePredMissingDataWarning,
|
|
558
|
+
stacklevel=2,
|
|
559
|
+
)
|
|
560
|
+
return hits
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def compute_phos_freq(records: list[dict[str, str]]) -> dict[str, float]:
|
|
564
|
+
"""Compute phosphorylation-site frequency from PhosphoSitePlus.
|
|
565
|
+
|
|
566
|
+
Returns per-accession site count / sequence length for the human set.
|
|
567
|
+
"""
|
|
568
|
+
if not PHOSPHOSITE_PATH.exists():
|
|
569
|
+
warnings.warn(
|
|
570
|
+
(
|
|
571
|
+
f"PhosphoSitePlus dataset missing at {PHOSPHOSITE_PATH}. "
|
|
572
|
+
"Human-mode predictions (hSaPS / hPdPS) will use median "
|
|
573
|
+
"imputation for the 'Phos freq' column. See the README "
|
|
574
|
+
"section 'Optional human-feature data' for the registration "
|
|
575
|
+
"and download steps."
|
|
576
|
+
),
|
|
577
|
+
PhaSePredMissingDataWarning,
|
|
578
|
+
stacklevel=2,
|
|
579
|
+
)
|
|
580
|
+
return {}
|
|
581
|
+
from phasepred.legacy_features import compute_phosphosite_frequencies
|
|
582
|
+
|
|
583
|
+
seq_rows: list[dict[str, object]] = [
|
|
584
|
+
{"UniprotEntry": r["accession"], "sequence": r["sequence"]} for r in records
|
|
585
|
+
]
|
|
586
|
+
features = compute_phosphosite_frequencies(seq_rows, PHOSPHOSITE_PATH)
|
|
587
|
+
hits = {f.accession: f.value for f in features}
|
|
588
|
+
requested = {r["accession"] for r in records}
|
|
589
|
+
missing = sorted(requested - set(hits.keys()))
|
|
590
|
+
if missing:
|
|
591
|
+
preview = ", ".join(missing[:5]) + ("…" if len(missing) > 5 else "")
|
|
592
|
+
warnings.warn(
|
|
593
|
+
(
|
|
594
|
+
f"PhosphoSitePlus has no entry for {len(missing)}/{len(requested)} "
|
|
595
|
+
f"requested protein(s): {preview}. Median imputation will be "
|
|
596
|
+
"used for 'Phos freq', so hSaPS/hPdPS scores for these "
|
|
597
|
+
"proteins should be treated as approximate."
|
|
598
|
+
),
|
|
599
|
+
PhaSePredMissingDataWarning,
|
|
600
|
+
stacklevel=2,
|
|
601
|
+
)
|
|
602
|
+
return hits
|