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/predictor.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""PhaSePred predictor: compute features and score proteins from FASTA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from phasepred.data import models_root
|
|
12
|
+
|
|
13
|
+
# Default artifact (single product): Product A — paper split recomputed.
|
|
14
|
+
DEFAULT_MODELS_DIR = models_root()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Feature computation
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def compute_all_features(
|
|
23
|
+
records: list[dict[str, str]],
|
|
24
|
+
*,
|
|
25
|
+
include_human: bool = False,
|
|
26
|
+
verbose: bool = True,
|
|
27
|
+
) -> pd.DataFrame:
|
|
28
|
+
"""Compute all PhaSePred features for a list of FASTA records.
|
|
29
|
+
|
|
30
|
+
Each record is {"accession": str, "sequence": str}.
|
|
31
|
+
Returns a DataFrame with feature columns ready for prediction.
|
|
32
|
+
"""
|
|
33
|
+
from catgranule import CatGranuleInputError, score_sequence
|
|
34
|
+
|
|
35
|
+
from phasepred import tools
|
|
36
|
+
from phasepred.features import compute_native_features as _native
|
|
37
|
+
|
|
38
|
+
t_start = time.time()
|
|
39
|
+
|
|
40
|
+
# --- Native features (pure Python, fast) ---
|
|
41
|
+
native_rows = []
|
|
42
|
+
for r in records:
|
|
43
|
+
seq = str(r["sequence"]).upper()
|
|
44
|
+
try:
|
|
45
|
+
feats = _native(seq)
|
|
46
|
+
native_rows.append(
|
|
47
|
+
{
|
|
48
|
+
"UniprotEntry": r["accession"],
|
|
49
|
+
"length": feats["length"],
|
|
50
|
+
"Hydropathy": feats["Hydropathy"],
|
|
51
|
+
"FCR": feats["FCR"],
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
except Exception:
|
|
55
|
+
native_rows.append(
|
|
56
|
+
{
|
|
57
|
+
"UniprotEntry": r["accession"],
|
|
58
|
+
"length": float("nan"),
|
|
59
|
+
"Hydropathy": float("nan"),
|
|
60
|
+
"FCR": float("nan"),
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
feature_df = pd.DataFrame(native_rows)
|
|
65
|
+
if verbose:
|
|
66
|
+
print(f" Native features: {len(feature_df)} in {time.time() - t_start:.1f}s")
|
|
67
|
+
|
|
68
|
+
# --- catGRANULE (via the catgranule package, legacy-unaudited weights) ---
|
|
69
|
+
t0 = time.time()
|
|
70
|
+
cat_scores: dict[str, float] = {}
|
|
71
|
+
for r in records:
|
|
72
|
+
try:
|
|
73
|
+
cat_scores[r["accession"]] = float(score_sequence(str(r["sequence"]))["single"])
|
|
74
|
+
except CatGranuleInputError:
|
|
75
|
+
cat_scores[r["accession"]] = float("nan")
|
|
76
|
+
feature_df["catGRANULE"] = feature_df["UniprotEntry"].map(cat_scores)
|
|
77
|
+
if verbose:
|
|
78
|
+
n = feature_df["catGRANULE"].notna().sum()
|
|
79
|
+
print(f" catGRANULE: {n}/{len(feature_df)} in {time.time() - t0:.1f}s")
|
|
80
|
+
|
|
81
|
+
# --- ESpritz IDR ---
|
|
82
|
+
t0 = time.time()
|
|
83
|
+
espritz_scores = tools.run_espritz(records)
|
|
84
|
+
feature_df["IDR"] = feature_df["UniprotEntry"].map(espritz_scores)
|
|
85
|
+
if verbose:
|
|
86
|
+
n = feature_df["IDR"].notna().sum()
|
|
87
|
+
print(f" ESpritz IDR: {n}/{len(feature_df)} in {time.time() - t0:.1f}s")
|
|
88
|
+
|
|
89
|
+
# --- SEG LCR ---
|
|
90
|
+
t0 = time.time()
|
|
91
|
+
seg_scores = tools.run_seg(records)
|
|
92
|
+
feature_df["LCR"] = feature_df["UniprotEntry"].map(seg_scores)
|
|
93
|
+
if verbose:
|
|
94
|
+
n = feature_df["LCR"].notna().sum()
|
|
95
|
+
print(f" SEG LCR: {n}/{len(feature_df)} in {time.time() - t0:.1f}s")
|
|
96
|
+
|
|
97
|
+
# --- PScore (parallel) ---
|
|
98
|
+
t0 = time.time()
|
|
99
|
+
pscore_scores = tools.run_pscore(records)
|
|
100
|
+
feature_df["PScore"] = feature_df["UniprotEntry"].map(pscore_scores)
|
|
101
|
+
if verbose:
|
|
102
|
+
n = feature_df["PScore"].notna().sum()
|
|
103
|
+
print(f" PScore: {n}/{len(feature_df)} in {time.time() - t0:.1f}s")
|
|
104
|
+
|
|
105
|
+
# --- PLAAC (parallel) ---
|
|
106
|
+
t0 = time.time()
|
|
107
|
+
plaac_scores = tools.run_plaac(records)
|
|
108
|
+
feature_df["PLAAC"] = feature_df["UniprotEntry"].map(plaac_scores)
|
|
109
|
+
if verbose:
|
|
110
|
+
n = feature_df["PLAAC"].notna().sum()
|
|
111
|
+
print(f" PLAAC: {n}/{len(feature_df)} in {time.time() - t0:.1f}s")
|
|
112
|
+
|
|
113
|
+
# --- DeepCoil (on-demand subprocess into isolated conda env; binary 0/1) ---
|
|
114
|
+
t0 = time.time()
|
|
115
|
+
dc_scores = tools.run_deepcoil(records)
|
|
116
|
+
feature_df["DeepCoil"] = feature_df["UniprotEntry"].map(dc_scores)
|
|
117
|
+
if verbose:
|
|
118
|
+
n = feature_df["DeepCoil"].notna().sum()
|
|
119
|
+
print(f" DeepCoil: {n}/{len(feature_df)} in {time.time() - t0:.1f}s")
|
|
120
|
+
|
|
121
|
+
# --- Human-specific features ---
|
|
122
|
+
if include_human:
|
|
123
|
+
t0 = time.time()
|
|
124
|
+
phos_scores = tools.compute_phos_freq(records)
|
|
125
|
+
feature_df["Phos freq"] = feature_df["UniprotEntry"].map(phos_scores)
|
|
126
|
+
dp_scores = tools.lookup_deepphase([r["accession"] for r in records])
|
|
127
|
+
feature_df["DeepPhase"] = feature_df["UniprotEntry"].map(dp_scores)
|
|
128
|
+
if verbose:
|
|
129
|
+
n_p = feature_df["Phos freq"].notna().sum()
|
|
130
|
+
n_d = feature_df["DeepPhase"].notna().sum()
|
|
131
|
+
print(f" Phos freq + DeepPhase: {n_p}, {n_d} in {time.time() - t0:.1f}s")
|
|
132
|
+
|
|
133
|
+
if verbose:
|
|
134
|
+
total_t = time.time() - t_start
|
|
135
|
+
print(f" Total feature computation: {total_t:.0f}s")
|
|
136
|
+
|
|
137
|
+
return feature_df
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Model loading and prediction
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_ensemble(model_dir: Path) -> list:
|
|
146
|
+
"""Load a 10-model XGBoost ensemble from a directory of joblib files."""
|
|
147
|
+
import joblib
|
|
148
|
+
|
|
149
|
+
model_files = sorted(model_dir.glob("8f_model_*.joblib"))
|
|
150
|
+
if not model_files:
|
|
151
|
+
raise FileNotFoundError(f"No 8f_model_*.joblib files found in {model_dir}")
|
|
152
|
+
return [joblib.load(str(f)) for f in model_files]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_single_model(model_path: Path):
|
|
156
|
+
"""Load a single model artifact (joblib file)."""
|
|
157
|
+
import joblib
|
|
158
|
+
|
|
159
|
+
if not model_path.exists():
|
|
160
|
+
raise FileNotFoundError(f"Model not found: {model_path}")
|
|
161
|
+
return joblib.load(str(model_path))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def predict_scores(
|
|
165
|
+
models: list,
|
|
166
|
+
features: pd.DataFrame,
|
|
167
|
+
feature_columns: list[str],
|
|
168
|
+
) -> pd.DataFrame:
|
|
169
|
+
"""Predict scores using a loaded ensemble (or single model).
|
|
170
|
+
|
|
171
|
+
If the model was trained on a subset of columns (e.g. paper baseline
|
|
172
|
+
with only 3 features), use only those columns from the feature table.
|
|
173
|
+
|
|
174
|
+
Returns DataFrame with columns: UniprotEntry, score
|
|
175
|
+
"""
|
|
176
|
+
# Check if the model artifact specifies its own feature columns
|
|
177
|
+
model_columns = None
|
|
178
|
+
if isinstance(models, list) and models:
|
|
179
|
+
first = models[0]
|
|
180
|
+
if hasattr(first, "feature_columns"):
|
|
181
|
+
model_columns = list(first.feature_columns)
|
|
182
|
+
elif hasattr(models, "feature_columns"):
|
|
183
|
+
model_columns = list(models.feature_columns)
|
|
184
|
+
|
|
185
|
+
effective_columns = model_columns or feature_columns
|
|
186
|
+
avail_cols = [c for c in effective_columns if c in features.columns]
|
|
187
|
+
missing = set(effective_columns) - set(avail_cols)
|
|
188
|
+
if missing:
|
|
189
|
+
for col in missing:
|
|
190
|
+
features[col] = float("nan")
|
|
191
|
+
|
|
192
|
+
X = features[avail_cols]
|
|
193
|
+
|
|
194
|
+
# models may be a single object or a list
|
|
195
|
+
model_list = models if isinstance(models, list) else [models]
|
|
196
|
+
|
|
197
|
+
if len(model_list) > 1:
|
|
198
|
+
scores = np.zeros(len(X))
|
|
199
|
+
for model in model_list:
|
|
200
|
+
scores += _model_predict_proba(model, X)[:, 1]
|
|
201
|
+
scores /= len(model_list)
|
|
202
|
+
else:
|
|
203
|
+
scores = _model_predict_proba(model_list[0], X)[:, 1]
|
|
204
|
+
|
|
205
|
+
result = features[["UniprotEntry"]].copy()
|
|
206
|
+
result["score"] = scores
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _model_predict_proba(model, X: pd.DataFrame) -> np.ndarray:
|
|
211
|
+
"""Call predict_proba, handling both sklearn Pipeline and ModelArtifact."""
|
|
212
|
+
if hasattr(model, "estimator"):
|
|
213
|
+
# ModelArtifact wrapper
|
|
214
|
+
return model.estimator.predict_proba(X)
|
|
215
|
+
return model.predict_proba(X)
|
phasepred/sequences.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
import requests
|
|
13
|
+
from Bio import SeqIO
|
|
14
|
+
|
|
15
|
+
UNIPROT_FASTA_BASE_URL = "https://rest.uniprot.org/uniprotkb"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ProteinSequence:
|
|
20
|
+
accession: str
|
|
21
|
+
sequence: str
|
|
22
|
+
source: str
|
|
23
|
+
header: str
|
|
24
|
+
retrieved_at: str | None = None
|
|
25
|
+
endpoint: str | None = None
|
|
26
|
+
sequence_hash: str = field(init=False)
|
|
27
|
+
length: int = field(init=False)
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
normalized = self.sequence.replace(" ", "").replace("\n", "").upper()
|
|
31
|
+
if not normalized:
|
|
32
|
+
raise ValueError(f"Protein sequence for {self.accession!r} is empty")
|
|
33
|
+
object.__setattr__(self, "sequence", normalized)
|
|
34
|
+
object.__setattr__(self, "length", len(normalized))
|
|
35
|
+
object.__setattr__(
|
|
36
|
+
self,
|
|
37
|
+
"sequence_hash",
|
|
38
|
+
hashlib.sha256(normalized.encode("utf-8")).hexdigest(),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def to_json(self) -> dict[str, object]:
|
|
42
|
+
payload = asdict(self)
|
|
43
|
+
payload.pop("sequence_hash", None)
|
|
44
|
+
payload.pop("length", None)
|
|
45
|
+
payload["sequence_hash"] = self.sequence_hash
|
|
46
|
+
payload["length"] = self.length
|
|
47
|
+
return payload
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_json(cls, payload: dict[str, Any]) -> ProteinSequence:
|
|
51
|
+
retrieved_at = payload.get("retrieved_at")
|
|
52
|
+
endpoint = payload.get("endpoint")
|
|
53
|
+
return cls(
|
|
54
|
+
accession=str(payload["accession"]),
|
|
55
|
+
sequence=str(payload["sequence"]),
|
|
56
|
+
source=str(payload["source"]),
|
|
57
|
+
header=str(payload.get("header") or ""),
|
|
58
|
+
retrieved_at=str(retrieved_at) if retrieved_at else None,
|
|
59
|
+
endpoint=str(endpoint) if endpoint else None,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SequenceFetcher(Protocol):
|
|
64
|
+
def fetch_fasta(self, accession: str) -> ProteinSequence: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SequenceCache:
|
|
68
|
+
def __init__(self, path: str | Path) -> None:
|
|
69
|
+
self.path = Path(path)
|
|
70
|
+
|
|
71
|
+
def get(self, accession: str) -> ProteinSequence | None:
|
|
72
|
+
accession = accession.strip()
|
|
73
|
+
for record in self._read_all().values():
|
|
74
|
+
if record.accession == accession:
|
|
75
|
+
return record
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def put(self, record: ProteinSequence) -> None:
|
|
79
|
+
records = self._read_all()
|
|
80
|
+
records[record.accession] = record
|
|
81
|
+
self.write_all(records)
|
|
82
|
+
|
|
83
|
+
def write_all(self, records: dict[str, ProteinSequence]) -> None:
|
|
84
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
with self.path.open("w", encoding="utf-8") as handle:
|
|
86
|
+
for item in records.values():
|
|
87
|
+
handle.write(json.dumps(item.to_json(), sort_keys=True) + "\n")
|
|
88
|
+
|
|
89
|
+
def _read_all(self) -> dict[str, ProteinSequence]:
|
|
90
|
+
if not self.path.exists():
|
|
91
|
+
return {}
|
|
92
|
+
records: dict[str, ProteinSequence] = {}
|
|
93
|
+
with self.path.open("r", encoding="utf-8") as handle:
|
|
94
|
+
for line in handle:
|
|
95
|
+
stripped = line.strip()
|
|
96
|
+
if not stripped:
|
|
97
|
+
continue
|
|
98
|
+
record = ProteinSequence.from_json(json.loads(stripped))
|
|
99
|
+
records[record.accession] = record
|
|
100
|
+
return records
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class UniProtClient:
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
base_url: str = UNIPROT_FASTA_BASE_URL,
|
|
108
|
+
session: requests.Session | None = None,
|
|
109
|
+
timeout: float = 30.0,
|
|
110
|
+
) -> None:
|
|
111
|
+
self.base_url = base_url.rstrip("/")
|
|
112
|
+
self.session = session or requests.Session()
|
|
113
|
+
self.timeout = timeout
|
|
114
|
+
|
|
115
|
+
def fetch_fasta(self, accession: str) -> ProteinSequence:
|
|
116
|
+
accession = accession.strip()
|
|
117
|
+
endpoint = f"{self.base_url}/{accession}.fasta"
|
|
118
|
+
response = self.session.get(
|
|
119
|
+
endpoint,
|
|
120
|
+
headers={"Accept": "text/x-fasta"},
|
|
121
|
+
timeout=self.timeout,
|
|
122
|
+
)
|
|
123
|
+
response.raise_for_status()
|
|
124
|
+
records = _records_from_fasta_text(response.text, source="uniprot", endpoint=endpoint)
|
|
125
|
+
if not records:
|
|
126
|
+
raise ValueError(f"UniProt returned no FASTA record for {accession}")
|
|
127
|
+
record = records[0]
|
|
128
|
+
return ProteinSequence(
|
|
129
|
+
accession=accession,
|
|
130
|
+
sequence=record.sequence,
|
|
131
|
+
source="uniprot",
|
|
132
|
+
header=record.header,
|
|
133
|
+
retrieved_at=datetime.now(UTC).isoformat(),
|
|
134
|
+
endpoint=endpoint,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def read_fasta_records(path: str | Path) -> list[ProteinSequence]:
|
|
139
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
140
|
+
return _records_from_fasta_text(text, source="fasta", endpoint=None)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def resolve_accessions(
|
|
144
|
+
accessions: list[str],
|
|
145
|
+
*,
|
|
146
|
+
cache_path: str | Path,
|
|
147
|
+
client: SequenceFetcher | None = None,
|
|
148
|
+
allow_network: bool = True,
|
|
149
|
+
) -> list[ProteinSequence]:
|
|
150
|
+
cache = SequenceCache(cache_path)
|
|
151
|
+
fetcher = client or UniProtClient()
|
|
152
|
+
records: list[ProteinSequence] = []
|
|
153
|
+
for accession in accessions:
|
|
154
|
+
normalized = accession.strip()
|
|
155
|
+
if not normalized:
|
|
156
|
+
continue
|
|
157
|
+
cached = cache.get(normalized)
|
|
158
|
+
if cached is not None:
|
|
159
|
+
records.append(cached)
|
|
160
|
+
continue
|
|
161
|
+
if not allow_network:
|
|
162
|
+
raise ValueError(f"Sequence for {normalized} is not cached and network is disabled")
|
|
163
|
+
fetched = fetcher.fetch_fasta(normalized)
|
|
164
|
+
cache.put(fetched)
|
|
165
|
+
records.append(fetched)
|
|
166
|
+
return records
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def sequence_records_to_frame(records: list[ProteinSequence]) -> pd.DataFrame:
|
|
170
|
+
return pd.DataFrame(
|
|
171
|
+
[
|
|
172
|
+
{
|
|
173
|
+
"UniprotEntry": record.accession,
|
|
174
|
+
"sequence": record.sequence,
|
|
175
|
+
"source": record.source,
|
|
176
|
+
"header": record.header,
|
|
177
|
+
"retrieved_at": record.retrieved_at,
|
|
178
|
+
"endpoint": record.endpoint,
|
|
179
|
+
"length": record.length,
|
|
180
|
+
"sequence_hash": record.sequence_hash,
|
|
181
|
+
}
|
|
182
|
+
for record in records
|
|
183
|
+
]
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _records_from_fasta_text(
|
|
188
|
+
text: str,
|
|
189
|
+
*,
|
|
190
|
+
source: str,
|
|
191
|
+
endpoint: str | None,
|
|
192
|
+
) -> list[ProteinSequence]:
|
|
193
|
+
from io import StringIO
|
|
194
|
+
|
|
195
|
+
records: list[ProteinSequence] = []
|
|
196
|
+
for seq_record in SeqIO.parse(StringIO(text), "fasta"):
|
|
197
|
+
header = f">{seq_record.description}"
|
|
198
|
+
accession = _accession_from_header(seq_record.id)
|
|
199
|
+
records.append(
|
|
200
|
+
ProteinSequence(
|
|
201
|
+
accession=accession,
|
|
202
|
+
sequence=str(seq_record.seq),
|
|
203
|
+
source=source,
|
|
204
|
+
header=header,
|
|
205
|
+
endpoint=endpoint,
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
if not records:
|
|
209
|
+
raise ValueError("No FASTA records found")
|
|
210
|
+
return records
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _accession_from_header(identifier: str) -> str:
|
|
214
|
+
parts = identifier.split("|")
|
|
215
|
+
if len(parts) >= 2 and parts[0] in {"sp", "tr"}:
|
|
216
|
+
return parts[1]
|
|
217
|
+
match = re.match(r"([A-Za-z0-9_.-]+)", identifier)
|
|
218
|
+
if not match:
|
|
219
|
+
raise ValueError(f"Cannot parse FASTA accession from header {identifier!r}")
|
|
220
|
+
return match.group(1)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Per-tool feature decomposition (the ``tool-features`` CLI surface).
|
|
2
|
+
|
|
3
|
+
Each PhaSePred feature is produced by an upstream tool (or a native/table
|
|
4
|
+
computation). This module computes them *individually* so a caller can
|
|
5
|
+
decompose a protein's PS-related profile tool-by-tool, matching the web
|
|
6
|
+
``tools.<x>`` payloads:
|
|
7
|
+
|
|
8
|
+
* summary value per tool (the ``single``/``NLLR``/``FCR`` column),
|
|
9
|
+
* optional per-residue arrays (``--residue-json``), for the tools whose local
|
|
10
|
+
contracts expose them.
|
|
11
|
+
|
|
12
|
+
Tool name vocabulary is aligned with the web API (PLAN §6-S7): ``espritz,
|
|
13
|
+
seg, pscore, plaac, catgranule, deepcoil, deepphase, phos, hydropathy,
|
|
14
|
+
charge``. A single tool failing degrades to ``NaN`` for that tool only — the
|
|
15
|
+
whole run never aborts (same degrade semantics as ``predict``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import cast
|
|
22
|
+
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
from phasepred import tools
|
|
26
|
+
from phasepred.features import CHARGED_RESIDUES, compute_native_features
|
|
27
|
+
|
|
28
|
+
# Tool vocabulary (web API order).
|
|
29
|
+
TOOL_NAMES = [
|
|
30
|
+
"espritz",
|
|
31
|
+
"seg",
|
|
32
|
+
"pscore",
|
|
33
|
+
"plaac",
|
|
34
|
+
"catgranule",
|
|
35
|
+
"deepcoil",
|
|
36
|
+
"deepphase",
|
|
37
|
+
"phos",
|
|
38
|
+
"hydropathy",
|
|
39
|
+
"charge",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# Tools whose local contract exposes per-residue arrays for --residue-json.
|
|
43
|
+
_RESIDUE_TOOLS = {"espritz", "seg", "catgranule", "deepcoil", "charge"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _catgranule(records: list[dict[str, str]]) -> tuple[dict[str, float], dict[str, dict]]:
|
|
47
|
+
from catgranule import score_sequence
|
|
48
|
+
|
|
49
|
+
singles: dict[str, float] = {}
|
|
50
|
+
residues: dict[str, dict] = {}
|
|
51
|
+
for r in records:
|
|
52
|
+
seq = str(r["sequence"]).upper()
|
|
53
|
+
try:
|
|
54
|
+
scored = score_sequence(seq)
|
|
55
|
+
singles[r["accession"]] = float(scored["single"])
|
|
56
|
+
residues[r["accession"]] = {"residue": [float(x) for x in scored["residue"]]}
|
|
57
|
+
except Exception:
|
|
58
|
+
singles[r["accession"]] = float("nan")
|
|
59
|
+
residues[r["accession"]] = {"residue": []}
|
|
60
|
+
return singles, residues
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _charge_residues(records: list[dict[str, str]]) -> dict[str, dict]:
|
|
64
|
+
out: dict[str, dict] = {}
|
|
65
|
+
for r in records:
|
|
66
|
+
seq = str(r["sequence"]).upper()
|
|
67
|
+
label = [1.0 if c in CHARGED_RESIDUES else 0.0 for c in seq]
|
|
68
|
+
pos_pos = [i for i, c in enumerate(seq) if c in {"K", "R"}]
|
|
69
|
+
pos_neg = [i for i, c in enumerate(seq) if c in {"D", "E"}]
|
|
70
|
+
out[r["accession"]] = {"label": label, "POS_pos": pos_pos, "POS_neg": pos_neg}
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def compute_tool_features(
|
|
75
|
+
records: list[dict[str, str]],
|
|
76
|
+
tool_names: list[str],
|
|
77
|
+
*,
|
|
78
|
+
residue: bool = False,
|
|
79
|
+
) -> tuple[pd.DataFrame, dict[str, dict[str, dict]]]:
|
|
80
|
+
"""Compute per-tool summary values (and optional residue arrays).
|
|
81
|
+
|
|
82
|
+
Returns ``(summary_df, residue_map)``. ``summary_df`` has ``UniprotEntry``
|
|
83
|
+
plus one column per requested tool (NaN on degrade). ``residue_map`` maps
|
|
84
|
+
``accession -> tool -> field -> list`` (empty when ``residue`` is False).
|
|
85
|
+
"""
|
|
86
|
+
requested = [t for t in tool_names if t in TOOL_NAMES]
|
|
87
|
+
accs = [r["accession"] for r in records]
|
|
88
|
+
summary: dict[str, list[object]] = {t: [float("nan")] * len(accs) for t in requested}
|
|
89
|
+
residue_map: dict[str, dict[str, dict]] = {a: {} for a in accs}
|
|
90
|
+
|
|
91
|
+
# Native (hydropathy, charge) — pure Python, no tools.
|
|
92
|
+
native: dict[str, dict[str, float]] = {a: {} for a in accs}
|
|
93
|
+
for r in records:
|
|
94
|
+
try:
|
|
95
|
+
native[r["accession"]] = compute_native_features(str(r["sequence"]).upper())
|
|
96
|
+
except Exception:
|
|
97
|
+
native[r["accession"]] = {}
|
|
98
|
+
|
|
99
|
+
if "hydropathy" in requested:
|
|
100
|
+
for i, r in enumerate(records):
|
|
101
|
+
summary["hydropathy"][i] = native[r["accession"]].get("Hydropathy", float("nan"))
|
|
102
|
+
if "charge" in requested:
|
|
103
|
+
for i, r in enumerate(records):
|
|
104
|
+
summary["charge"][i] = native[r["accession"]].get("FCR", float("nan"))
|
|
105
|
+
if residue:
|
|
106
|
+
for a, ch_payload in _charge_residues(records).items():
|
|
107
|
+
residue_map[a]["charge"] = ch_payload
|
|
108
|
+
|
|
109
|
+
# catgranule (package) — pure Python.
|
|
110
|
+
if "catgranule" in requested:
|
|
111
|
+
singles, residues = _catgranule(records)
|
|
112
|
+
for i, r in enumerate(records):
|
|
113
|
+
summary["catgranule"][i] = singles[r["accession"]]
|
|
114
|
+
if residue:
|
|
115
|
+
for a, cg_payload in residues.items():
|
|
116
|
+
residue_map[a]["catgranule"] = cg_payload
|
|
117
|
+
|
|
118
|
+
# ESpritz (IDR) — perl tool; residue arrays derivable.
|
|
119
|
+
if "espritz" in requested:
|
|
120
|
+
esp_resid: dict[str, dict[str, object]] = (
|
|
121
|
+
tools.espritz_residues(records) if residue else {}
|
|
122
|
+
)
|
|
123
|
+
if not residue:
|
|
124
|
+
for i, acc in enumerate(accs):
|
|
125
|
+
summary["espritz"][i] = tools.run_espritz(records).get(acc, float("nan"))
|
|
126
|
+
else:
|
|
127
|
+
for i, r in enumerate(records):
|
|
128
|
+
esp_payload = esp_resid.get(r["accession"])
|
|
129
|
+
if esp_payload is None:
|
|
130
|
+
summary["espritz"][i] = float("nan")
|
|
131
|
+
continue
|
|
132
|
+
esp_label = cast(list[str], esp_payload["label"])
|
|
133
|
+
summary["espritz"][i] = (
|
|
134
|
+
sum(1 for s in esp_label if s == "D") / len(esp_label)
|
|
135
|
+
if esp_label
|
|
136
|
+
else float("nan")
|
|
137
|
+
)
|
|
138
|
+
residue_map[r["accession"]]["espritz"] = {
|
|
139
|
+
"label": esp_label,
|
|
140
|
+
"residue": cast(list[float], esp_payload["residue"]),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# SEG (LCR) — native binary; residue mask derivable.
|
|
144
|
+
if "seg" in requested:
|
|
145
|
+
seg_resid: dict[str, dict[str, object]] = tools.seg_residues(records) if residue else {}
|
|
146
|
+
if not residue:
|
|
147
|
+
for i, acc in enumerate(accs):
|
|
148
|
+
summary["seg"][i] = tools.run_seg(records).get(acc, float("nan"))
|
|
149
|
+
else:
|
|
150
|
+
for i, r in enumerate(records):
|
|
151
|
+
seg_payload = seg_resid.get(r["accession"])
|
|
152
|
+
if seg_payload is None:
|
|
153
|
+
summary["seg"][i] = float("nan")
|
|
154
|
+
continue
|
|
155
|
+
seg_label = cast(list[float], seg_payload["label"])
|
|
156
|
+
summary["seg"][i] = sum(seg_label) / len(seg_label) if seg_label else float("nan")
|
|
157
|
+
residue_map[r["accession"]]["seg"] = {"label": seg_label}
|
|
158
|
+
|
|
159
|
+
# DeepCoil (v2022 binary) — external env; residue arrays derivable.
|
|
160
|
+
if "deepcoil" in requested:
|
|
161
|
+
dc_resid: dict[str, dict[str, object]] = (
|
|
162
|
+
tools.deepcoil_residues(records) if residue else {}
|
|
163
|
+
)
|
|
164
|
+
if not residue:
|
|
165
|
+
for i, acc in enumerate(accs):
|
|
166
|
+
summary["deepcoil"][i] = tools.run_deepcoil(records).get(acc, float("nan"))
|
|
167
|
+
else:
|
|
168
|
+
for i, r in enumerate(records):
|
|
169
|
+
dc_payload = dc_resid.get(r["accession"])
|
|
170
|
+
if dc_payload is None:
|
|
171
|
+
summary["deepcoil"][i] = float("nan")
|
|
172
|
+
continue
|
|
173
|
+
raw_cc = cast(list[float], dc_payload["coiled-coil"])
|
|
174
|
+
summary["deepcoil"][i] = (
|
|
175
|
+
float(1.0 if raw_cc and max(raw_cc) >= tools.DEEPCOIL_THRESHOLD else 0.0)
|
|
176
|
+
if raw_cc
|
|
177
|
+
else float("nan")
|
|
178
|
+
)
|
|
179
|
+
residue_map[r["accession"]]["deepcoil"] = {
|
|
180
|
+
"coiled-coil": raw_cc,
|
|
181
|
+
"sharpen": cast(list[float], dc_payload["sharpen"]),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# PScore / PLAAC — single only (residue not exposed by local contract).
|
|
185
|
+
if "pscore" in requested:
|
|
186
|
+
vals = tools.run_pscore(records)
|
|
187
|
+
for i, r in enumerate(records):
|
|
188
|
+
summary["pscore"][i] = vals.get(r["accession"], float("nan"))
|
|
189
|
+
if "plaac" in requested:
|
|
190
|
+
vals = tools.run_plaac(records)
|
|
191
|
+
for i, r in enumerate(records):
|
|
192
|
+
summary["plaac"][i] = vals.get(r["accession"], float("nan"))
|
|
193
|
+
|
|
194
|
+
# Lookups (DeepPhase, PhosphoSitePlus).
|
|
195
|
+
if "deepphase" in requested:
|
|
196
|
+
vals = tools.lookup_deepphase(accs)
|
|
197
|
+
for i, acc in enumerate(accs):
|
|
198
|
+
summary["deepphase"][i] = vals.get(acc, float("nan"))
|
|
199
|
+
if "phos" in requested:
|
|
200
|
+
vals = tools.compute_phos_freq(records)
|
|
201
|
+
for i, r in enumerate(records):
|
|
202
|
+
summary["phos"][i] = vals.get(r["accession"], float("nan"))
|
|
203
|
+
|
|
204
|
+
frame = pd.DataFrame({"UniprotEntry": accs})
|
|
205
|
+
for t in requested:
|
|
206
|
+
frame[t] = summary[t]
|
|
207
|
+
return frame, residue_map
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def write_residue_json(residue_map: dict[str, dict[str, dict]], out_dir: Path) -> None:
|
|
211
|
+
"""Write one JSON file per accession: ``{tool: {field: [...]}}``."""
|
|
212
|
+
import json
|
|
213
|
+
|
|
214
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
for acc, tools_map in residue_map.items():
|
|
216
|
+
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in acc)
|
|
217
|
+
path = out_dir / f"{safe}.json"
|
|
218
|
+
path.write_text(json.dumps(tools_map, indent=2), encoding="utf-8")
|