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/cli.py
ADDED
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import math
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import matplotlib
|
|
10
|
+
|
|
11
|
+
matplotlib.use("Agg")
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
import requests
|
|
15
|
+
import typer
|
|
16
|
+
from requests import HTTPError, RequestException
|
|
17
|
+
from sklearn.metrics import average_precision_score, roc_auc_score
|
|
18
|
+
|
|
19
|
+
from phasepred import data
|
|
20
|
+
from phasepred.features import FeatureSchemaError, compute_native_features
|
|
21
|
+
from phasepred.legacy_features import (
|
|
22
|
+
compute_lcr_features,
|
|
23
|
+
compute_phosphosite_frequencies,
|
|
24
|
+
compute_plaac_features,
|
|
25
|
+
compute_pscore_features,
|
|
26
|
+
load_deepphase_scores,
|
|
27
|
+
native_feature_frame,
|
|
28
|
+
)
|
|
29
|
+
from phasepred.models import (
|
|
30
|
+
load_model_artifact,
|
|
31
|
+
predict_with_artifact,
|
|
32
|
+
train_model_artifact,
|
|
33
|
+
)
|
|
34
|
+
from phasepred.paper import load_paper_features, load_paper_split
|
|
35
|
+
from phasepred.sequences import (
|
|
36
|
+
UNIPROT_FASTA_BASE_URL,
|
|
37
|
+
ProteinSequence,
|
|
38
|
+
read_fasta_records,
|
|
39
|
+
resolve_accessions,
|
|
40
|
+
sequence_records_to_frame,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
app = typer.Typer(no_args_is_help=True)
|
|
44
|
+
|
|
45
|
+
# Default paths resolve through phasepred.data so commands work from any CWD
|
|
46
|
+
# and honor PHASEPRED_DATA_ROOT for repo-local datasets.
|
|
47
|
+
_DEFAULT_PHOSPHOSITE_PATH = data.data_path(
|
|
48
|
+
"data", "raw", "external", "phosphositeplus", "Phosphorylation_site_dataset.gz"
|
|
49
|
+
)
|
|
50
|
+
_DEFAULT_DEEPPHASE_PATH = data.data_path(
|
|
51
|
+
"data", "raw", "external", "deepphase", "extracted", "tableS3.xlsx"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
TEST_AUC_ROWS = [
|
|
56
|
+
{
|
|
57
|
+
"dataset": "SaPS-test",
|
|
58
|
+
"PScore": 0.80,
|
|
59
|
+
"PLAAC": 0.82,
|
|
60
|
+
"catGRANULE": 0.80,
|
|
61
|
+
"FuzDrop": 0.81,
|
|
62
|
+
"PhaSePred": 0.86,
|
|
63
|
+
"PhaSePred remake": 0.8202521101294108,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"dataset": "PdPS-test",
|
|
67
|
+
"PScore": 0.61,
|
|
68
|
+
"PLAAC": 0.62,
|
|
69
|
+
"catGRANULE": 0.65,
|
|
70
|
+
"FuzDrop": 0.59,
|
|
71
|
+
"PhaSePred": 0.66,
|
|
72
|
+
"PhaSePred remake": 0.7111709980362504,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"dataset": "hSaPS-test",
|
|
76
|
+
"PScore": 0.79,
|
|
77
|
+
"PLAAC": 0.79,
|
|
78
|
+
"catGRANULE": 0.77,
|
|
79
|
+
"FuzDrop": 0.74,
|
|
80
|
+
"PhaSePred": 0.85,
|
|
81
|
+
"PhaSePred remake": 0.8694098883572569,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"dataset": "hPdPS-test",
|
|
85
|
+
"PScore": 0.58,
|
|
86
|
+
"PLAAC": 0.58,
|
|
87
|
+
"catGRANULE": 0.66,
|
|
88
|
+
"FuzDrop": 0.55,
|
|
89
|
+
"PhaSePred": 0.78,
|
|
90
|
+
"PhaSePred remake": 0.8379463307776561,
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("prepare-split")
|
|
96
|
+
def prepare_split(
|
|
97
|
+
s2: Annotated[Path, typer.Option("--s2", exists=True, readable=True)],
|
|
98
|
+
s3: Annotated[Path, typer.Option("--s3", exists=True, readable=True)],
|
|
99
|
+
output: Annotated[Path, typer.Option("--output")],
|
|
100
|
+
) -> None:
|
|
101
|
+
split = load_paper_split(s2, s3)
|
|
102
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
split.all_rows.to_csv(output, index=False)
|
|
104
|
+
typer.echo(f"Wrote {len(split.all_rows)} split rows to {output}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command("features-from-fasta")
|
|
108
|
+
def features_from_fasta(
|
|
109
|
+
input_path: Annotated[Path, typer.Option("--input", exists=True, readable=True)],
|
|
110
|
+
output: Annotated[Path, typer.Option("--output")],
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Compute v2022 sequence features from a FASTA file.
|
|
113
|
+
|
|
114
|
+
Emits native features (Hydropathy, FCR) plus the ESpritz-domain IDR
|
|
115
|
+
fraction and the SEG-domain LCR fraction. Missing external tools degrade
|
|
116
|
+
the corresponding column to NaN with a warning (v2022 definitions).
|
|
117
|
+
"""
|
|
118
|
+
records = read_fasta_records(input_path)
|
|
119
|
+
rows = []
|
|
120
|
+
idr_rows = _compute_espritz_idr_fraction(records)
|
|
121
|
+
lcr_rows = _compute_lcr_fraction(records)
|
|
122
|
+
for record in records:
|
|
123
|
+
row = {
|
|
124
|
+
"UniprotEntry": record.accession,
|
|
125
|
+
"sequence": record.sequence,
|
|
126
|
+
**compute_native_features(record.sequence),
|
|
127
|
+
}
|
|
128
|
+
row["espritz-idr-fraction"] = idr_rows.get(record.accession, float("nan"))
|
|
129
|
+
row["lcr-fraction"] = lcr_rows.get(record.accession, float("nan"))
|
|
130
|
+
rows.append(row)
|
|
131
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
pd.DataFrame(rows).to_csv(output, index=False)
|
|
133
|
+
typer.echo(f"Wrote {len(rows)} v2022 feature rows to {output}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _compute_espritz_idr_fraction(records) -> dict[str, float]:
|
|
137
|
+
"""IDR fraction (ESpritz DisProt, 5% FPR) with NaN degradation.
|
|
138
|
+
|
|
139
|
+
Emits a warning on stderr so the NaN degradation is always visible.
|
|
140
|
+
"""
|
|
141
|
+
try:
|
|
142
|
+
from phasepred.tools import run_espritz
|
|
143
|
+
|
|
144
|
+
return run_espritz([{"accession": r.accession, "sequence": r.sequence} for r in records])
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
typer.echo(
|
|
147
|
+
f"WARNING: ESpritz unavailable; 'espritz-idr-fraction' set to NaN "
|
|
148
|
+
f"({exc}).",
|
|
149
|
+
err=True,
|
|
150
|
+
)
|
|
151
|
+
return {}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _compute_lcr_fraction(records) -> dict[str, float]:
|
|
155
|
+
"""LCR fraction (SEG default parameters) with NaN degradation."""
|
|
156
|
+
try:
|
|
157
|
+
from phasepred.tools import run_seg
|
|
158
|
+
|
|
159
|
+
return run_seg([{"accession": r.accession, "sequence": r.sequence} for r in records])
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
typer.echo(
|
|
162
|
+
f"WARNING: SEG unavailable; 'lcr-fraction' set to NaN ({exc}).",
|
|
163
|
+
err=True,
|
|
164
|
+
)
|
|
165
|
+
return {}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command("sequences-from-ids")
|
|
169
|
+
def sequences_from_ids(
|
|
170
|
+
input_path: Annotated[Path, typer.Option("--input", exists=True, readable=True)],
|
|
171
|
+
cache: Annotated[Path, typer.Option("--cache")],
|
|
172
|
+
output: Annotated[Path, typer.Option("--output")],
|
|
173
|
+
allow_network: Annotated[bool, typer.Option("--allow-network/--no-network")] = True,
|
|
174
|
+
) -> None:
|
|
175
|
+
frame = _read_table(input_path)
|
|
176
|
+
if "UniprotEntry" not in frame.columns:
|
|
177
|
+
raise typer.BadParameter("Input table must contain a UniprotEntry column")
|
|
178
|
+
accessions = list(dict.fromkeys(frame["UniprotEntry"].dropna().astype(str).str.strip()))
|
|
179
|
+
records = resolve_accessions(
|
|
180
|
+
accessions,
|
|
181
|
+
cache_path=cache,
|
|
182
|
+
allow_network=allow_network,
|
|
183
|
+
)
|
|
184
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
sequence_records_to_frame(records).to_csv(output, index=False)
|
|
186
|
+
typer.echo(f"Wrote {len(records)} sequence records to {output}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command("features-from-paper")
|
|
190
|
+
def features_from_paper(
|
|
191
|
+
s2: Annotated[Path, typer.Option("--s2", exists=True, readable=True)],
|
|
192
|
+
s3: Annotated[Path, typer.Option("--s3", exists=True, readable=True)],
|
|
193
|
+
output: Annotated[Path, typer.Option("--output")],
|
|
194
|
+
) -> None:
|
|
195
|
+
features = load_paper_features(s2, s3)
|
|
196
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
features.all_rows.to_csv(output, index=False)
|
|
198
|
+
typer.echo(f"Wrote {len(features.all_rows)} paper feature rows to {output}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.command("features-from-split")
|
|
202
|
+
def features_from_split(
|
|
203
|
+
split_table: Annotated[Path, typer.Option("--split-table", exists=True, readable=True)],
|
|
204
|
+
sequences: Annotated[Path, typer.Option("--sequences", exists=True, readable=True)],
|
|
205
|
+
output: Annotated[Path, typer.Option("--output")],
|
|
206
|
+
) -> None:
|
|
207
|
+
split = _read_table(split_table)
|
|
208
|
+
sequence_frame = _read_table(sequences)
|
|
209
|
+
merged = split.merge(sequence_frame, on="UniprotEntry", how="left")
|
|
210
|
+
if merged["sequence"].isna().any():
|
|
211
|
+
missing = merged.loc[merged["sequence"].isna(), "UniprotEntry"].tolist()
|
|
212
|
+
raise typer.BadParameter(f"Missing sequences for accessions: {', '.join(missing)}")
|
|
213
|
+
rows = []
|
|
214
|
+
for row in merged.itertuples(index=False):
|
|
215
|
+
features = {
|
|
216
|
+
"task": row.task,
|
|
217
|
+
"split": row.split,
|
|
218
|
+
"label": row.label,
|
|
219
|
+
"UniprotEntry": row.UniprotEntry,
|
|
220
|
+
"sequence": row.sequence,
|
|
221
|
+
**compute_native_features(row.sequence),
|
|
222
|
+
}
|
|
223
|
+
rows.append(features)
|
|
224
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
pd.DataFrame(rows).to_csv(output, index=False)
|
|
226
|
+
typer.echo(f"Wrote {len(rows)} split feature rows to {output}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@app.command("features-recomputed")
|
|
230
|
+
def features_recomputed(
|
|
231
|
+
sequences: Annotated[Path, typer.Option("--sequences", exists=True, readable=True)],
|
|
232
|
+
output: Annotated[Path, typer.Option("--output")],
|
|
233
|
+
espritz_cache: Annotated[Path | None, typer.Option("--espritz-cache")] = None,
|
|
234
|
+
include_human: Annotated[bool, typer.Option("--include-human/--base-only")] = False,
|
|
235
|
+
phosphosite: Annotated[
|
|
236
|
+
Path,
|
|
237
|
+
typer.Option("--phosphosite", exists=True, readable=True),
|
|
238
|
+
] = _DEFAULT_PHOSPHOSITE_PATH,
|
|
239
|
+
deepphase: Annotated[
|
|
240
|
+
Path,
|
|
241
|
+
typer.Option("--deepphase", exists=True, readable=True),
|
|
242
|
+
] = _DEFAULT_DEEPPHASE_PATH,
|
|
243
|
+
catgranule_features: Annotated[
|
|
244
|
+
Path | None,
|
|
245
|
+
typer.Option("--catgranule-features", exists=True, readable=True),
|
|
246
|
+
] = None,
|
|
247
|
+
catgranule_source: Annotated[
|
|
248
|
+
str,
|
|
249
|
+
typer.Option(
|
|
250
|
+
"--catgranule-source",
|
|
251
|
+
help="catGRANULE source: 'package' (catgranule pkg) or 'csv'.",
|
|
252
|
+
),
|
|
253
|
+
] = "package",
|
|
254
|
+
deepcoil_features: Annotated[
|
|
255
|
+
Path | None,
|
|
256
|
+
typer.Option("--deepcoil-features", exists=True, readable=True),
|
|
257
|
+
] = None,
|
|
258
|
+
pscore_features: Annotated[
|
|
259
|
+
Path | None,
|
|
260
|
+
typer.Option("--pscore-features", exists=True, readable=True),
|
|
261
|
+
] = None,
|
|
262
|
+
plaac_features: Annotated[
|
|
263
|
+
Path | None,
|
|
264
|
+
typer.Option("--plaac-features", exists=True, readable=True),
|
|
265
|
+
] = None,
|
|
266
|
+
) -> None:
|
|
267
|
+
sequence_frame = _read_table(sequences)
|
|
268
|
+
if "UniprotEntry" not in sequence_frame.columns or "sequence" not in sequence_frame.columns:
|
|
269
|
+
raise typer.BadParameter("Sequence table must contain UniprotEntry and sequence columns")
|
|
270
|
+
rows = sequence_frame[["UniprotEntry", "sequence"]].drop_duplicates("UniprotEntry")
|
|
271
|
+
sequence_rows = rows.to_dict(orient="records")
|
|
272
|
+
feature_frame = native_feature_frame(sequence_rows)
|
|
273
|
+
|
|
274
|
+
lcr = pd.DataFrame([f.to_json() for f in compute_lcr_features(sequence_rows)])
|
|
275
|
+
if pscore_features is not None:
|
|
276
|
+
pscore = _read_table(pscore_features)
|
|
277
|
+
if "UniprotEntry" not in pscore.columns or "PScore" not in pscore.columns:
|
|
278
|
+
raise typer.BadParameter(
|
|
279
|
+
"--pscore-features must contain UniprotEntry and PScore columns"
|
|
280
|
+
)
|
|
281
|
+
else:
|
|
282
|
+
pscore = pd.DataFrame([f.to_json() for f in compute_pscore_features(sequence_rows)])
|
|
283
|
+
if plaac_features is not None:
|
|
284
|
+
plaac = _read_table(plaac_features)
|
|
285
|
+
if "UniprotEntry" not in plaac.columns or "PLAAC" not in plaac.columns:
|
|
286
|
+
raise typer.BadParameter("--plaac-features must contain UniprotEntry and PLAAC columns")
|
|
287
|
+
else:
|
|
288
|
+
plaac = pd.DataFrame([f.to_json() for f in compute_plaac_features(sequence_rows)])
|
|
289
|
+
feature_frame = _merge_scalar_feature(feature_frame, lcr, "LCR")
|
|
290
|
+
feature_frame = _merge_scalar_feature(feature_frame, pscore, "PScore")
|
|
291
|
+
feature_frame = _merge_scalar_feature(feature_frame, plaac, "PLAAC")
|
|
292
|
+
|
|
293
|
+
if espritz_cache is None:
|
|
294
|
+
raise typer.BadParameter("--espritz-cache is required (ESpritz is the only IDR source)")
|
|
295
|
+
from phasepred.espritz import ESpritzFeatureCache
|
|
296
|
+
|
|
297
|
+
idr = pd.DataFrame(
|
|
298
|
+
[f.to_json() for f in ESpritzFeatureCache(espritz_cache)._read_all().values()]
|
|
299
|
+
).rename(columns={"accession": "UniprotEntry", "espritz_idr": "IDR"})
|
|
300
|
+
feature_frame = feature_frame.merge(idr[["UniprotEntry", "IDR"]], on="UniprotEntry", how="left")
|
|
301
|
+
|
|
302
|
+
catgranule_source_normalized = catgranule_source.strip().lower()
|
|
303
|
+
if catgranule_source_normalized == "csv":
|
|
304
|
+
if catgranule_features is None:
|
|
305
|
+
raise typer.BadParameter(
|
|
306
|
+
"--catgranule-features is required when --catgranule-source csv"
|
|
307
|
+
)
|
|
308
|
+
catgranule_frame = _read_table(catgranule_features)
|
|
309
|
+
elif catgranule_source_normalized == "package":
|
|
310
|
+
from catgranule import CatGranuleInputError, score_sequence
|
|
311
|
+
|
|
312
|
+
catgranule_frame = _compute_catgranule_package_features(
|
|
313
|
+
sequence_rows, score_sequence, CatGranuleInputError
|
|
314
|
+
)
|
|
315
|
+
else:
|
|
316
|
+
raise typer.BadParameter("--catgranule-source must be 'package' or 'csv'")
|
|
317
|
+
if deepcoil_features is None:
|
|
318
|
+
raise typer.BadParameter(
|
|
319
|
+
"--deepcoil-features is required for full 8/10 feature recomputation; "
|
|
320
|
+
"DeepCoil is not installed in the project Python 3.12 environment."
|
|
321
|
+
)
|
|
322
|
+
deepcoil_frame = _read_table(deepcoil_features)
|
|
323
|
+
for label, frame, column in (
|
|
324
|
+
("catgranule", catgranule_frame, "catGRANULE"),
|
|
325
|
+
("deepcoil", deepcoil_frame, "DeepCoil"),
|
|
326
|
+
):
|
|
327
|
+
missing = {"UniprotEntry", column} - set(frame.columns)
|
|
328
|
+
if missing:
|
|
329
|
+
raise typer.BadParameter(
|
|
330
|
+
f"{label} feature table missing required columns: {', '.join(sorted(missing))}"
|
|
331
|
+
)
|
|
332
|
+
feature_frame = feature_frame.merge(
|
|
333
|
+
catgranule_frame[["UniprotEntry", "catGRANULE"]],
|
|
334
|
+
on="UniprotEntry",
|
|
335
|
+
how="left",
|
|
336
|
+
)
|
|
337
|
+
feature_frame = feature_frame.merge(
|
|
338
|
+
deepcoil_frame[["UniprotEntry", "DeepCoil"]],
|
|
339
|
+
on="UniprotEntry",
|
|
340
|
+
how="left",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if include_human:
|
|
344
|
+
phospho = pd.DataFrame(
|
|
345
|
+
[f.to_json() for f in compute_phosphosite_frequencies(sequence_rows, phosphosite)]
|
|
346
|
+
)
|
|
347
|
+
feature_frame = _merge_scalar_feature(feature_frame, phospho, "Phos freq")
|
|
348
|
+
feature_frame = feature_frame.merge(
|
|
349
|
+
load_deepphase_scores(deepphase),
|
|
350
|
+
on="UniprotEntry",
|
|
351
|
+
how="left",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
355
|
+
feature_frame.to_csv(output, index=False)
|
|
356
|
+
typer.echo(f"Wrote {len(feature_frame)} recomputed feature rows to {output}")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@app.command("train")
|
|
360
|
+
def train(
|
|
361
|
+
split_table: Annotated[Path, typer.Option("--split-table", exists=True, readable=True)],
|
|
362
|
+
features: Annotated[Path, typer.Option("--features", exists=True, readable=True)],
|
|
363
|
+
task: Annotated[str, typer.Option("--task")],
|
|
364
|
+
output: Annotated[Path, typer.Option("--output")],
|
|
365
|
+
) -> None:
|
|
366
|
+
split = _read_table(split_table)
|
|
367
|
+
feature_frame = _read_table(features)
|
|
368
|
+
task_split = split.query("task == @task and split == 'train'")
|
|
369
|
+
if task_split.empty:
|
|
370
|
+
raise typer.BadParameter(f"No train rows found for task {task}")
|
|
371
|
+
merge_keys = ["UniprotEntry"]
|
|
372
|
+
if "task" in feature_frame.columns:
|
|
373
|
+
merge_keys.append("task")
|
|
374
|
+
train_frame = task_split.merge(feature_frame, on=merge_keys, how="left")
|
|
375
|
+
artifact = train_model_artifact(train_frame, label_column="label", task=task)
|
|
376
|
+
artifact.dump(output)
|
|
377
|
+
typer.echo(f"Wrote {task} model to {output}")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@app.command("predict-features")
|
|
381
|
+
def predict_features(
|
|
382
|
+
model: Annotated[Path, typer.Option("--model", exists=True, readable=True)],
|
|
383
|
+
features: Annotated[Path, typer.Option("--features", exists=True, readable=True)],
|
|
384
|
+
output: Annotated[Path, typer.Option("--output")],
|
|
385
|
+
) -> None:
|
|
386
|
+
artifact = load_model_artifact(model)
|
|
387
|
+
frame = _read_table(features)
|
|
388
|
+
predictions = predict_with_artifact(artifact, frame)
|
|
389
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
390
|
+
predictions.to_csv(output, index=False)
|
|
391
|
+
typer.echo(f"Wrote {len(predictions)} predictions to {output}")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@app.command("predict")
|
|
395
|
+
def predict(
|
|
396
|
+
mode: Annotated[str, typer.Option("--mode", help="SaPS, PdPS, hSaPS, or hPdPS")],
|
|
397
|
+
output: Annotated[Path, typer.Option("--output")],
|
|
398
|
+
fasta: Annotated[
|
|
399
|
+
Path | None,
|
|
400
|
+
typer.Option("--fasta", help="FASTA file with protein sequences."),
|
|
401
|
+
] = None,
|
|
402
|
+
ids: Annotated[
|
|
403
|
+
str | None,
|
|
404
|
+
typer.Option(
|
|
405
|
+
"--ids",
|
|
406
|
+
help="UniProt accessions. Three accepted forms: a comma-separated "
|
|
407
|
+
"inline list ('P35637,Q9Y2W1'), a path to a file with one ID "
|
|
408
|
+
"per line, or '-' to read IDs from stdin. Sequences are fetched "
|
|
409
|
+
"from rest.uniprot.org and cached locally.",
|
|
410
|
+
),
|
|
411
|
+
] = None,
|
|
412
|
+
cache: Annotated[
|
|
413
|
+
Path | None,
|
|
414
|
+
typer.Option(
|
|
415
|
+
"--cache",
|
|
416
|
+
help="JSONL cache for UniProt fetches (default: data/interim/uniprot_cache.jsonl).",
|
|
417
|
+
),
|
|
418
|
+
] = None,
|
|
419
|
+
models_dir: Annotated[
|
|
420
|
+
Path | None,
|
|
421
|
+
typer.Option(
|
|
422
|
+
"--models-dir",
|
|
423
|
+
help="Override the default Product A model dir entirely. Expects "
|
|
424
|
+
"<dir>/<mode>/8f_model_*.joblib.",
|
|
425
|
+
),
|
|
426
|
+
] = None,
|
|
427
|
+
) -> None:
|
|
428
|
+
"""Predict phase separation scores for proteins.
|
|
429
|
+
|
|
430
|
+
Provide proteins via `--fasta`, `--ids`, or both. `--ids` accepts:
|
|
431
|
+
* a comma-separated inline list: `--ids P35637,Q9Y2W1`
|
|
432
|
+
* a file with one accession per line: `--ids my_ids.txt`
|
|
433
|
+
* standard input: `cat my_ids.txt | phasepred predict --ids - ...`
|
|
434
|
+
|
|
435
|
+
Examples:
|
|
436
|
+
phasepred predict --fasta my_proteins.fasta --mode SaPS --output scores.csv
|
|
437
|
+
phasepred predict --ids "P35637,Q9Y2W1" --mode hSaPS --output scores.csv
|
|
438
|
+
"""
|
|
439
|
+
from phasepred.features import BASE_FEATURE_COLUMNS, HUMAN_FEATURE_COLUMNS
|
|
440
|
+
from phasepred.models import model_dir_is_stale
|
|
441
|
+
from phasepred.predictor import (
|
|
442
|
+
DEFAULT_MODELS_DIR,
|
|
443
|
+
compute_all_features,
|
|
444
|
+
load_ensemble,
|
|
445
|
+
load_single_model,
|
|
446
|
+
predict_scores,
|
|
447
|
+
)
|
|
448
|
+
from phasepred.sequences import resolve_accessions
|
|
449
|
+
from phasepred.tool_paths import (
|
|
450
|
+
PhaSePredToolNotFound,
|
|
451
|
+
assert_predict_requirements,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
if fasta is None and ids is None:
|
|
455
|
+
raise typer.BadParameter("Provide at least one of --fasta or --ids.")
|
|
456
|
+
|
|
457
|
+
mode = mode.strip()
|
|
458
|
+
if mode not in ("SaPS", "PdPS", "hSaPS", "hPdPS"):
|
|
459
|
+
raise typer.BadParameter(
|
|
460
|
+
f"Unknown mode: {mode}. Must be SaPS, PdPS, hSaPS, or hPdPS."
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
chosen_models_dir = models_dir if models_dir is not None else DEFAULT_MODELS_DIR
|
|
464
|
+
|
|
465
|
+
# Preflight: every external tool that `predict` needs must resolve up-front.
|
|
466
|
+
# If anything is MISSING the user gets a clear error before we read any input.
|
|
467
|
+
try:
|
|
468
|
+
assert_predict_requirements()
|
|
469
|
+
except PhaSePredToolNotFound as e:
|
|
470
|
+
typer.echo(str(e), err=True)
|
|
471
|
+
raise typer.Exit(code=1) from e
|
|
472
|
+
|
|
473
|
+
records: list[dict[str, str]] = []
|
|
474
|
+
seen: set[str] = set()
|
|
475
|
+
|
|
476
|
+
# --- FASTA input ---
|
|
477
|
+
if fasta is not None:
|
|
478
|
+
if not fasta.exists() or not fasta.is_file():
|
|
479
|
+
raise typer.BadParameter(f"--fasta path not found or not a file: {fasta}")
|
|
480
|
+
fasta_text = Path(fasta).read_text(encoding="utf-8")
|
|
481
|
+
fasta_records = list(_parse_fasta_records(fasta_text))
|
|
482
|
+
if not fasta_records:
|
|
483
|
+
raise typer.BadParameter(f"No FASTA records found in {fasta}")
|
|
484
|
+
for acc, seq, _hdr in fasta_records:
|
|
485
|
+
if acc in seen:
|
|
486
|
+
continue
|
|
487
|
+
records.append({"accession": acc, "sequence": seq})
|
|
488
|
+
seen.add(acc)
|
|
489
|
+
typer.echo(f"Read {len(fasta_records)} record(s) from {fasta}")
|
|
490
|
+
|
|
491
|
+
# --- IDs input ---
|
|
492
|
+
if ids is not None:
|
|
493
|
+
accessions = _resolve_ids_argument(ids)
|
|
494
|
+
accessions = [a for a in accessions if a and a not in seen]
|
|
495
|
+
if accessions:
|
|
496
|
+
cache_path = cache if cache is not None else data.data_path(
|
|
497
|
+
"data", "interim", "uniprot_cache.jsonl"
|
|
498
|
+
)
|
|
499
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
500
|
+
typer.echo(f"Resolving {len(accessions)} UniProt ID(s) via {cache_path}...")
|
|
501
|
+
try:
|
|
502
|
+
fetched = resolve_accessions(accessions, cache_path=cache_path, allow_network=True)
|
|
503
|
+
except Exception as e:
|
|
504
|
+
typer.echo(f"UniProt resolution failed: {e}", err=True)
|
|
505
|
+
raise typer.Exit(code=1) from e
|
|
506
|
+
for rec in fetched:
|
|
507
|
+
if rec.accession in seen:
|
|
508
|
+
continue
|
|
509
|
+
records.append({"accession": rec.accession, "sequence": rec.sequence})
|
|
510
|
+
seen.add(rec.accession)
|
|
511
|
+
typer.echo(f"Resolved {len(fetched)} ID(s) to sequences")
|
|
512
|
+
|
|
513
|
+
if not records:
|
|
514
|
+
raise typer.BadParameter(
|
|
515
|
+
"No protein records to score after merging --fasta and --ids inputs."
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
# Compute features
|
|
519
|
+
is_human = mode.startswith("h")
|
|
520
|
+
typer.echo(f"Computing features for {len(records)} protein(s), mode={mode}...")
|
|
521
|
+
feature_df = compute_all_features(records, include_human=is_human)
|
|
522
|
+
|
|
523
|
+
# Load model(s)
|
|
524
|
+
model_path_or_dir = chosen_models_dir / f"{mode}.joblib"
|
|
525
|
+
if model_path_or_dir.exists():
|
|
526
|
+
typer.echo(f"Loading model from {model_path_or_dir}")
|
|
527
|
+
model = load_single_model(model_path_or_dir)
|
|
528
|
+
models = [model]
|
|
529
|
+
stale = True
|
|
530
|
+
stale_reason = (
|
|
531
|
+
f"model artifact {model_path_or_dir} carries no feature-definition "
|
|
532
|
+
"version tag (legacy or externally trained model)"
|
|
533
|
+
)
|
|
534
|
+
else:
|
|
535
|
+
model_dir = chosen_models_dir / mode
|
|
536
|
+
if not model_dir.exists():
|
|
537
|
+
raise typer.BadParameter(
|
|
538
|
+
f"Model not found at {model_path_or_dir} or {model_dir}.\n"
|
|
539
|
+
f"Ensure the Product A models exist under {DEFAULT_MODELS_DIR}, "
|
|
540
|
+
"or use --models-dir to override."
|
|
541
|
+
)
|
|
542
|
+
typer.echo(f"Loading ensemble from {model_dir}")
|
|
543
|
+
models = load_ensemble(model_dir)
|
|
544
|
+
stale, stale_reason = model_dir_is_stale(model_dir)
|
|
545
|
+
|
|
546
|
+
if stale:
|
|
547
|
+
typer.echo(
|
|
548
|
+
"\n".join(
|
|
549
|
+
[
|
|
550
|
+
"WARNING: model features are STALE.",
|
|
551
|
+
f" {stale_reason}.",
|
|
552
|
+
" Scores are produced with feature definitions that differ "
|
|
553
|
+
"from the current code (feature_definitions_version v2022).",
|
|
554
|
+
" The output CSV carries a 'stale' column so downstream "
|
|
555
|
+
"consumers can detect this.",
|
|
556
|
+
]
|
|
557
|
+
),
|
|
558
|
+
err=True,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# Predict
|
|
562
|
+
columns = HUMAN_FEATURE_COLUMNS if is_human else BASE_FEATURE_COLUMNS
|
|
563
|
+
predictions = predict_scores(models, feature_df, list(columns))
|
|
564
|
+
|
|
565
|
+
# Merge features for informative output
|
|
566
|
+
output_df = predictions.merge(feature_df, on="UniprotEntry", how="left")
|
|
567
|
+
output_df["stale"] = stale
|
|
568
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
569
|
+
output_df.to_csv(output, index=False)
|
|
570
|
+
|
|
571
|
+
for _, row in output_df.iterrows():
|
|
572
|
+
typer.echo(f" {row['UniprotEntry']}: score={row['score']:.4f}")
|
|
573
|
+
typer.echo(f"Wrote {len(output_df)} predictions to {output}")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _resolve_ids_argument(raw: str) -> list[str]:
|
|
577
|
+
"""Resolve the --ids argument to a list of accessions.
|
|
578
|
+
|
|
579
|
+
Accepts:
|
|
580
|
+
* '-': read whitespace-separated tokens from stdin
|
|
581
|
+
* existing file path: one accession per line (blank lines / # comments allowed)
|
|
582
|
+
* otherwise: comma-separated inline string
|
|
583
|
+
Whitespace is stripped; duplicates are deduplicated preserving order.
|
|
584
|
+
"""
|
|
585
|
+
import sys as _sys
|
|
586
|
+
|
|
587
|
+
raw = raw.strip()
|
|
588
|
+
if raw == "-":
|
|
589
|
+
tokens = _sys.stdin.read().split()
|
|
590
|
+
else:
|
|
591
|
+
candidate = Path(raw)
|
|
592
|
+
if candidate.is_file():
|
|
593
|
+
tokens = []
|
|
594
|
+
for line in candidate.read_text(encoding="utf-8").splitlines():
|
|
595
|
+
line = line.strip()
|
|
596
|
+
if not line or line.startswith("#"):
|
|
597
|
+
continue
|
|
598
|
+
tokens.append(line)
|
|
599
|
+
else:
|
|
600
|
+
tokens = [tok.strip() for tok in raw.split(",")]
|
|
601
|
+
seen: dict[str, None] = {}
|
|
602
|
+
for tok in tokens:
|
|
603
|
+
if tok and tok not in seen:
|
|
604
|
+
seen[tok] = None
|
|
605
|
+
return list(seen.keys())
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@app.command("check-tools")
|
|
609
|
+
def check_tools(
|
|
610
|
+
strict: Annotated[
|
|
611
|
+
bool,
|
|
612
|
+
typer.Option(
|
|
613
|
+
"--strict/--no-strict",
|
|
614
|
+
help="Exit with code 1 if any required-for-predict tool is MISSING.",
|
|
615
|
+
),
|
|
616
|
+
] = False,
|
|
617
|
+
) -> None:
|
|
618
|
+
"""Report the install status of every external feature tool.
|
|
619
|
+
|
|
620
|
+
Each tool is a contract package under tools/<TOOL>/ (run +
|
|
621
|
+
manifest.toml + install.sh + README.md). Resolution cascade per tool:
|
|
622
|
+
PHASEPRED_<TOOL>_DIR, the vendored tools/<TOOL>/ package, PATH
|
|
623
|
+
(manifest path_executables), then missing. Usability is verified with
|
|
624
|
+
each package's ./run --check. Run install.sh after installing.
|
|
625
|
+
"""
|
|
626
|
+
from phasepred.tool_paths import check_all_tools, format_status_table
|
|
627
|
+
|
|
628
|
+
statuses = check_all_tools()
|
|
629
|
+
typer.echo(format_status_table(statuses))
|
|
630
|
+
|
|
631
|
+
if strict:
|
|
632
|
+
missing_required = [s for s in statuses if not s.ok and s.required_for_predict]
|
|
633
|
+
if missing_required:
|
|
634
|
+
raise typer.Exit(code=1)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
@app.command("tool-features")
|
|
638
|
+
def tool_features(
|
|
639
|
+
fasta: Annotated[
|
|
640
|
+
Path | None,
|
|
641
|
+
typer.Option("--input", help="FASTA file with proteins to decompose."),
|
|
642
|
+
] = None,
|
|
643
|
+
ids: Annotated[
|
|
644
|
+
str | None,
|
|
645
|
+
typer.Option(
|
|
646
|
+
"--ids",
|
|
647
|
+
help="UniProt accessions: inline comma list, a file (one per line), "
|
|
648
|
+
"or '-' for stdin.",
|
|
649
|
+
),
|
|
650
|
+
] = None,
|
|
651
|
+
tools_arg: Annotated[
|
|
652
|
+
str,
|
|
653
|
+
typer.Option(
|
|
654
|
+
"--tools",
|
|
655
|
+
help="Comma-separated tool list, or 'all'. "
|
|
656
|
+
"Vocabulary: espritz,seg,pscore,plaac,catgranule,deepcoil,"
|
|
657
|
+
"deepphase,phos,hydropathy,charge.",
|
|
658
|
+
),
|
|
659
|
+
] = "all",
|
|
660
|
+
output: Annotated[Path, typer.Option("--output", help="CSV summary output.")] = Path(
|
|
661
|
+
"tool-features.csv"
|
|
662
|
+
),
|
|
663
|
+
residue_json: Annotated[
|
|
664
|
+
Path | None,
|
|
665
|
+
typer.Option(
|
|
666
|
+
"--residue-json",
|
|
667
|
+
help="Directory for per-protein per-tool residue-level JSON.",
|
|
668
|
+
),
|
|
669
|
+
] = None,
|
|
670
|
+
cache: Annotated[Path | None, typer.Option("--cache")] = None,
|
|
671
|
+
allow_network: Annotated[bool, typer.Option("--allow-network/--no-network")] = True,
|
|
672
|
+
) -> None:
|
|
673
|
+
"""Decompose each protein's PS profile tool-by-tool.
|
|
674
|
+
|
|
675
|
+
Summary values per tool mirror the web ``tools.<x>`` payloads; a single
|
|
676
|
+
failing tool degrades to NaN (never aborts). ``--residue-json`` adds
|
|
677
|
+
per-residue arrays for espritz/seg/catgranule/deepcoil/charge.
|
|
678
|
+
"""
|
|
679
|
+
from phasepred.sequences import resolve_accessions
|
|
680
|
+
from phasepred.tool_features import (
|
|
681
|
+
TOOL_NAMES,
|
|
682
|
+
compute_tool_features,
|
|
683
|
+
write_residue_json,
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
if fasta is None and ids is None:
|
|
687
|
+
raise typer.BadParameter("Provide at least one of --input or --ids.")
|
|
688
|
+
|
|
689
|
+
requested = (
|
|
690
|
+
list(TOOL_NAMES)
|
|
691
|
+
if tools_arg.strip().lower() == "all"
|
|
692
|
+
else [t.strip() for t in tools_arg.split(",") if t.strip()]
|
|
693
|
+
)
|
|
694
|
+
unknown = [t for t in requested if t not in TOOL_NAMES]
|
|
695
|
+
if unknown:
|
|
696
|
+
raise typer.BadParameter(
|
|
697
|
+
f"Unknown tool(s): {', '.join(unknown)}. Valid: {', '.join(TOOL_NAMES)}"
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
records: list[dict[str, str]] = []
|
|
701
|
+
seen: set[str] = set()
|
|
702
|
+
|
|
703
|
+
if fasta is not None:
|
|
704
|
+
if not fasta.exists():
|
|
705
|
+
raise typer.BadParameter(f"--input path not found: {fasta}")
|
|
706
|
+
for acc, seq, _hdr in _parse_fasta_records(fasta.read_text(encoding="utf-8")):
|
|
707
|
+
if acc in seen:
|
|
708
|
+
continue
|
|
709
|
+
records.append({"accession": acc, "sequence": seq})
|
|
710
|
+
seen.add(acc)
|
|
711
|
+
|
|
712
|
+
if ids is not None:
|
|
713
|
+
accessions = [a for a in _resolve_ids_argument(ids) if a and a not in seen]
|
|
714
|
+
if accessions:
|
|
715
|
+
cache_path = cache if cache is not None else data.data_path(
|
|
716
|
+
"data", "interim", "uniprot_cache.jsonl"
|
|
717
|
+
)
|
|
718
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
719
|
+
fetched = resolve_accessions(
|
|
720
|
+
accessions, cache_path=cache_path, allow_network=allow_network
|
|
721
|
+
)
|
|
722
|
+
for rec in fetched:
|
|
723
|
+
if rec.accession in seen:
|
|
724
|
+
continue
|
|
725
|
+
records.append({"accession": rec.accession, "sequence": rec.sequence})
|
|
726
|
+
seen.add(rec.accession)
|
|
727
|
+
|
|
728
|
+
if not records:
|
|
729
|
+
raise typer.BadParameter("No proteins to decompose after merging inputs.")
|
|
730
|
+
|
|
731
|
+
typer.echo(f"Decomposing {len(records)} protein(s) across {len(requested)} tool(s)...")
|
|
732
|
+
summary, residue_map = compute_tool_features(
|
|
733
|
+
records, requested, residue=residue_json is not None
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
737
|
+
summary.to_csv(output, index=False)
|
|
738
|
+
typer.echo(f"Wrote {len(summary)} summary row(s) to {output}")
|
|
739
|
+
|
|
740
|
+
if residue_json is not None:
|
|
741
|
+
write_residue_json(residue_map, residue_json)
|
|
742
|
+
typer.echo(f"Wrote residue JSON to {residue_json}/")
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _read_table(path: Path) -> pd.DataFrame:
|
|
746
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
747
|
+
sample = next((line for line in text.splitlines() if line.strip()), "")
|
|
748
|
+
if not sample:
|
|
749
|
+
return pd.DataFrame()
|
|
750
|
+
delimiter = ","
|
|
751
|
+
if "\t" in sample and "," not in sample:
|
|
752
|
+
delimiter = "\t"
|
|
753
|
+
else:
|
|
754
|
+
try:
|
|
755
|
+
dialect = csv.Sniffer().sniff(sample, delimiters="\t;|,")
|
|
756
|
+
delimiter = dialect.delimiter
|
|
757
|
+
except csv.Error:
|
|
758
|
+
pass
|
|
759
|
+
return pd.read_csv(StringIO(text), sep=delimiter)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _frame_from_rows(
|
|
763
|
+
rows: list[dict[str, float | str]],
|
|
764
|
+
columns: list[str],
|
|
765
|
+
*,
|
|
766
|
+
index_column: str,
|
|
767
|
+
) -> pd.DataFrame:
|
|
768
|
+
frame = pd.DataFrame(rows).set_index(index_column)
|
|
769
|
+
missing = [column for column in columns if column not in frame.columns]
|
|
770
|
+
if missing:
|
|
771
|
+
raise typer.BadParameter(f"Missing AUC columns: {', '.join(missing)}")
|
|
772
|
+
return frame[columns]
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _compute_catgranule_package_features(
|
|
776
|
+
sequence_rows: list[dict[str, object]],
|
|
777
|
+
score_sequence_fn,
|
|
778
|
+
error_cls,
|
|
779
|
+
) -> pd.DataFrame:
|
|
780
|
+
rows = []
|
|
781
|
+
for record in sequence_rows:
|
|
782
|
+
accession = str(record["UniprotEntry"])
|
|
783
|
+
sequence = str(record["sequence"])
|
|
784
|
+
try:
|
|
785
|
+
score = float(score_sequence_fn(sequence)["single"])
|
|
786
|
+
rows.append({"UniprotEntry": accession, "catGRANULE": score})
|
|
787
|
+
except error_cls:
|
|
788
|
+
rows.append({"UniprotEntry": accession})
|
|
789
|
+
return pd.DataFrame(rows)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _merge_scalar_feature(
|
|
793
|
+
frame: pd.DataFrame,
|
|
794
|
+
scalar_frame: pd.DataFrame,
|
|
795
|
+
column: str,
|
|
796
|
+
) -> pd.DataFrame:
|
|
797
|
+
if scalar_frame.empty:
|
|
798
|
+
return frame.assign(**{column: np.nan})
|
|
799
|
+
values = scalar_frame.rename(columns={"accession": "UniprotEntry", "value": column})
|
|
800
|
+
return frame.merge(values[["UniprotEntry", column]], on="UniprotEntry", how="left")
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _resolve_accessions_for_comparison(
|
|
804
|
+
accessions: list[str],
|
|
805
|
+
*,
|
|
806
|
+
cache_path: Path,
|
|
807
|
+
allow_network: bool,
|
|
808
|
+
skip_missing: bool,
|
|
809
|
+
missing_output: Path,
|
|
810
|
+
):
|
|
811
|
+
if not skip_missing:
|
|
812
|
+
return resolve_accessions(
|
|
813
|
+
accessions,
|
|
814
|
+
cache_path=cache_path,
|
|
815
|
+
allow_network=allow_network,
|
|
816
|
+
)
|
|
817
|
+
from phasepred.sequences import SequenceCache
|
|
818
|
+
|
|
819
|
+
cache = SequenceCache(cache_path)
|
|
820
|
+
cached_records = cache._read_all()
|
|
821
|
+
records: list[ProteinSequence] = []
|
|
822
|
+
missing_rows: list[dict[str, str]] = []
|
|
823
|
+
missing_accessions: list[str] = []
|
|
824
|
+
seen: set[str] = set()
|
|
825
|
+
for accession in accessions:
|
|
826
|
+
normalized = accession.strip()
|
|
827
|
+
if not normalized or normalized in seen:
|
|
828
|
+
continue
|
|
829
|
+
seen.add(normalized)
|
|
830
|
+
if normalized in cached_records:
|
|
831
|
+
records.append(cached_records[normalized])
|
|
832
|
+
else:
|
|
833
|
+
missing_accessions.append(normalized)
|
|
834
|
+
|
|
835
|
+
if not allow_network:
|
|
836
|
+
missing_rows.extend(
|
|
837
|
+
{"UniprotEntry": accession, "error": "not cached"} for accession in missing_accessions
|
|
838
|
+
)
|
|
839
|
+
elif missing_accessions:
|
|
840
|
+
fetched_records, missing_rows = _fetch_uniprot_records_in_batches(
|
|
841
|
+
missing_accessions,
|
|
842
|
+
cache=cache,
|
|
843
|
+
cached_records=cached_records,
|
|
844
|
+
)
|
|
845
|
+
for record in fetched_records:
|
|
846
|
+
cached_records[record.accession] = record
|
|
847
|
+
records.append(record)
|
|
848
|
+
if missing_rows:
|
|
849
|
+
missing_output.parent.mkdir(parents=True, exist_ok=True)
|
|
850
|
+
pd.DataFrame(missing_rows).to_csv(missing_output, index=False)
|
|
851
|
+
return records
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _fetch_uniprot_records_in_batches(
|
|
855
|
+
accessions: list[str],
|
|
856
|
+
*,
|
|
857
|
+
cache,
|
|
858
|
+
cached_records: dict[str, ProteinSequence],
|
|
859
|
+
batch_size: int = 50,
|
|
860
|
+
):
|
|
861
|
+
from datetime import UTC, datetime
|
|
862
|
+
|
|
863
|
+
fetched: list[ProteinSequence] = []
|
|
864
|
+
missing_rows: list[dict[str, str]] = []
|
|
865
|
+
session = requests.Session()
|
|
866
|
+
for start in range(0, len(accessions), batch_size):
|
|
867
|
+
batch = accessions[start : start + batch_size]
|
|
868
|
+
try:
|
|
869
|
+
response = session.get(
|
|
870
|
+
f"{UNIPROT_FASTA_BASE_URL}/stream",
|
|
871
|
+
params={
|
|
872
|
+
"compressed": "false",
|
|
873
|
+
"format": "fasta",
|
|
874
|
+
"query": " OR ".join(f"accession:{accession}" for accession in batch),
|
|
875
|
+
},
|
|
876
|
+
timeout=120,
|
|
877
|
+
)
|
|
878
|
+
response.raise_for_status()
|
|
879
|
+
except HTTPError as exc:
|
|
880
|
+
status = exc.response.status_code if exc.response is not None else "unknown"
|
|
881
|
+
missing_rows.extend(
|
|
882
|
+
{"UniprotEntry": accession, "error": f"batch HTTP {status}"}
|
|
883
|
+
for accession in batch
|
|
884
|
+
)
|
|
885
|
+
continue
|
|
886
|
+
except RequestException as exc:
|
|
887
|
+
missing_rows.extend(
|
|
888
|
+
{"UniprotEntry": accession, "error": str(exc)} for accession in batch
|
|
889
|
+
)
|
|
890
|
+
continue
|
|
891
|
+
|
|
892
|
+
returned = {}
|
|
893
|
+
for accession, sequence, header in _parse_fasta_records(response.text):
|
|
894
|
+
if accession in batch:
|
|
895
|
+
fetched_record = ProteinSequence(
|
|
896
|
+
accession=accession,
|
|
897
|
+
sequence=sequence,
|
|
898
|
+
source="uniprot",
|
|
899
|
+
header=header,
|
|
900
|
+
retrieved_at=datetime.now(UTC).isoformat(),
|
|
901
|
+
endpoint=f"{UNIPROT_FASTA_BASE_URL}/stream",
|
|
902
|
+
)
|
|
903
|
+
returned[accession] = fetched_record
|
|
904
|
+
for accession in batch:
|
|
905
|
+
record: ProteinSequence | None = returned.get(accession)
|
|
906
|
+
if record is None:
|
|
907
|
+
missing_rows.append({"UniprotEntry": accession, "error": "not returned"})
|
|
908
|
+
continue
|
|
909
|
+
fetched.append(record)
|
|
910
|
+
cached_records[record.accession] = record
|
|
911
|
+
cache.write_all(cached_records)
|
|
912
|
+
return fetched, missing_rows
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _parse_fasta_records(text: str):
|
|
916
|
+
header: str | None = None
|
|
917
|
+
sequence_lines: list[str] = []
|
|
918
|
+
for line in text.splitlines():
|
|
919
|
+
if line.startswith(">"):
|
|
920
|
+
if header is not None:
|
|
921
|
+
yield _accession_from_fasta_header(header), "".join(sequence_lines), header
|
|
922
|
+
header = line
|
|
923
|
+
sequence_lines = []
|
|
924
|
+
elif header is not None:
|
|
925
|
+
sequence_lines.append(line.strip())
|
|
926
|
+
if header is not None:
|
|
927
|
+
yield _accession_from_fasta_header(header), "".join(sequence_lines), header
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _accession_from_fasta_header(header: str) -> str:
|
|
931
|
+
identifier = header[1:].split(maxsplit=1)[0]
|
|
932
|
+
parts = identifier.split("|")
|
|
933
|
+
if len(parts) >= 2 and parts[0] in {"sp", "tr"}:
|
|
934
|
+
return parts[1]
|
|
935
|
+
return identifier
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _train_and_score_paper_features(
|
|
939
|
+
features: pd.DataFrame,
|
|
940
|
+
*,
|
|
941
|
+
variant: str,
|
|
942
|
+
model_dir: Path,
|
|
943
|
+
prediction_dir: Path,
|
|
944
|
+
) -> list[dict[str, object]]:
|
|
945
|
+
model_dir.mkdir(parents=True, exist_ok=True)
|
|
946
|
+
prediction_dir.mkdir(parents=True, exist_ok=True)
|
|
947
|
+
rows: list[dict[str, object]] = []
|
|
948
|
+
for task in ("SaPS", "PdPS", "hSaPS", "hPdPS"):
|
|
949
|
+
train_frame = features.query("task == @task and split == 'train'").copy()
|
|
950
|
+
test_frame = features.query("task == @task and split == 'test'").copy()
|
|
951
|
+
if train_frame.empty:
|
|
952
|
+
raise typer.BadParameter(f"No training rows found for task {task}")
|
|
953
|
+
if test_frame.empty:
|
|
954
|
+
raise typer.BadParameter(f"No test rows found for task {task}")
|
|
955
|
+
artifact = train_model_artifact(train_frame, label_column="label", task=task)
|
|
956
|
+
model_path = model_dir / f"{task}.joblib"
|
|
957
|
+
artifact.dump(model_path)
|
|
958
|
+
predictions = predict_with_artifact(artifact, test_frame)
|
|
959
|
+
predictions.to_csv(prediction_dir / f"{task}_test_scores.csv", index=False)
|
|
960
|
+
y_true = test_frame["label"].astype(int)
|
|
961
|
+
y_score = predictions["score"]
|
|
962
|
+
rows.append(
|
|
963
|
+
{
|
|
964
|
+
"variant": variant,
|
|
965
|
+
"task": task,
|
|
966
|
+
"model_path": str(model_path),
|
|
967
|
+
"train_rows": len(train_frame),
|
|
968
|
+
"test_rows": len(test_frame),
|
|
969
|
+
"roc_auc": roc_auc_score(y_true, y_score)
|
|
970
|
+
if y_true.nunique() > 1
|
|
971
|
+
else math.nan,
|
|
972
|
+
"average_precision": average_precision_score(y_true, y_score)
|
|
973
|
+
if y_true.nunique() > 1
|
|
974
|
+
else math.nan,
|
|
975
|
+
}
|
|
976
|
+
)
|
|
977
|
+
return rows
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _native_features_from_sequences(sequence_frame: pd.DataFrame):
|
|
981
|
+
rows = []
|
|
982
|
+
errors = []
|
|
983
|
+
for record in sequence_frame.itertuples(index=False):
|
|
984
|
+
accession = str(record.UniprotEntry)
|
|
985
|
+
row = {
|
|
986
|
+
"UniprotEntry": accession,
|
|
987
|
+
"sequence_hash": getattr(record, "sequence_hash", None),
|
|
988
|
+
}
|
|
989
|
+
try:
|
|
990
|
+
row.update(compute_native_features(str(record.sequence)))
|
|
991
|
+
except FeatureSchemaError as exc:
|
|
992
|
+
errors.append({"UniprotEntry": accession, "error": str(exc)})
|
|
993
|
+
rows.append(row)
|
|
994
|
+
return rows, errors
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _train_and_score_feature_table(
|
|
998
|
+
features: pd.DataFrame,
|
|
999
|
+
*,
|
|
1000
|
+
variant: str,
|
|
1001
|
+
model_dir: Path,
|
|
1002
|
+
prediction_dir: Path,
|
|
1003
|
+
feature_columns: list[str],
|
|
1004
|
+
) -> list[dict[str, object]]:
|
|
1005
|
+
model_dir.mkdir(parents=True, exist_ok=True)
|
|
1006
|
+
prediction_dir.mkdir(parents=True, exist_ok=True)
|
|
1007
|
+
rows: list[dict[str, object]] = []
|
|
1008
|
+
for task in ("SaPS", "PdPS", "hSaPS", "hPdPS"):
|
|
1009
|
+
train_frame = features.query("task == @task and split == 'train'").copy()
|
|
1010
|
+
test_frame = features.query("task == @task and split == 'test'").copy()
|
|
1011
|
+
if train_frame.empty:
|
|
1012
|
+
raise typer.BadParameter(f"No training rows found for task {task}")
|
|
1013
|
+
if test_frame.empty:
|
|
1014
|
+
raise typer.BadParameter(f"No test rows found for task {task}")
|
|
1015
|
+
artifact = train_model_artifact(
|
|
1016
|
+
train_frame,
|
|
1017
|
+
label_column="label",
|
|
1018
|
+
task=task,
|
|
1019
|
+
feature_columns=feature_columns,
|
|
1020
|
+
)
|
|
1021
|
+
model_path = model_dir / f"{task}.joblib"
|
|
1022
|
+
artifact.dump(model_path)
|
|
1023
|
+
predictions = predict_with_artifact(artifact, test_frame)
|
|
1024
|
+
predictions.to_csv(prediction_dir / f"{task}_test_scores.csv", index=False)
|
|
1025
|
+
y_true = test_frame["label"].astype(int)
|
|
1026
|
+
y_score = predictions["score"]
|
|
1027
|
+
rows.append(
|
|
1028
|
+
{
|
|
1029
|
+
"variant": variant,
|
|
1030
|
+
"task": task,
|
|
1031
|
+
"model_path": str(model_path),
|
|
1032
|
+
"feature_columns": ";".join(feature_columns),
|
|
1033
|
+
"train_rows": len(train_frame),
|
|
1034
|
+
"test_rows": len(test_frame),
|
|
1035
|
+
"train_rows_with_missing_features": int(
|
|
1036
|
+
train_frame[feature_columns].isna().any(axis=1).sum()
|
|
1037
|
+
),
|
|
1038
|
+
"test_rows_with_missing_features": int(
|
|
1039
|
+
test_frame[feature_columns].isna().any(axis=1).sum()
|
|
1040
|
+
),
|
|
1041
|
+
"roc_auc": roc_auc_score(y_true, y_score)
|
|
1042
|
+
if y_true.nunique() > 1
|
|
1043
|
+
else math.nan,
|
|
1044
|
+
"average_precision": average_precision_score(y_true, y_score)
|
|
1045
|
+
if y_true.nunique() > 1
|
|
1046
|
+
else math.nan,
|
|
1047
|
+
}
|
|
1048
|
+
)
|
|
1049
|
+
return rows
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def main() -> None:
|
|
1053
|
+
app()
|