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.
Files changed (64) hide show
  1. phasepred/__init__.py +3 -0
  2. phasepred/cli.py +1053 -0
  3. phasepred/data/models/PdPS/8f_model_0.joblib +0 -0
  4. phasepred/data/models/PdPS/8f_model_1.joblib +0 -0
  5. phasepred/data/models/PdPS/8f_model_2.joblib +0 -0
  6. phasepred/data/models/PdPS/8f_model_3.joblib +0 -0
  7. phasepred/data/models/PdPS/8f_model_4.joblib +0 -0
  8. phasepred/data/models/PdPS/8f_model_5.joblib +0 -0
  9. phasepred/data/models/PdPS/8f_model_6.joblib +0 -0
  10. phasepred/data/models/PdPS/8f_model_7.joblib +0 -0
  11. phasepred/data/models/PdPS/8f_model_8.joblib +0 -0
  12. phasepred/data/models/PdPS/8f_model_9.joblib +0 -0
  13. phasepred/data/models/PdPS/manifest.json +25 -0
  14. phasepred/data/models/SaPS/8f_model_0.joblib +0 -0
  15. phasepred/data/models/SaPS/8f_model_1.joblib +0 -0
  16. phasepred/data/models/SaPS/8f_model_2.joblib +0 -0
  17. phasepred/data/models/SaPS/8f_model_3.joblib +0 -0
  18. phasepred/data/models/SaPS/8f_model_4.joblib +0 -0
  19. phasepred/data/models/SaPS/8f_model_5.joblib +0 -0
  20. phasepred/data/models/SaPS/8f_model_6.joblib +0 -0
  21. phasepred/data/models/SaPS/8f_model_7.joblib +0 -0
  22. phasepred/data/models/SaPS/8f_model_8.joblib +0 -0
  23. phasepred/data/models/SaPS/8f_model_9.joblib +0 -0
  24. phasepred/data/models/SaPS/manifest.json +25 -0
  25. phasepred/data/models/hPdPS/8f_model_0.joblib +0 -0
  26. phasepred/data/models/hPdPS/8f_model_1.joblib +0 -0
  27. phasepred/data/models/hPdPS/8f_model_2.joblib +0 -0
  28. phasepred/data/models/hPdPS/8f_model_3.joblib +0 -0
  29. phasepred/data/models/hPdPS/8f_model_4.joblib +0 -0
  30. phasepred/data/models/hPdPS/8f_model_5.joblib +0 -0
  31. phasepred/data/models/hPdPS/8f_model_6.joblib +0 -0
  32. phasepred/data/models/hPdPS/8f_model_7.joblib +0 -0
  33. phasepred/data/models/hPdPS/8f_model_8.joblib +0 -0
  34. phasepred/data/models/hPdPS/8f_model_9.joblib +0 -0
  35. phasepred/data/models/hPdPS/manifest.json +27 -0
  36. phasepred/data/models/hSaPS/8f_model_0.joblib +0 -0
  37. phasepred/data/models/hSaPS/8f_model_1.joblib +0 -0
  38. phasepred/data/models/hSaPS/8f_model_2.joblib +0 -0
  39. phasepred/data/models/hSaPS/8f_model_3.joblib +0 -0
  40. phasepred/data/models/hSaPS/8f_model_4.joblib +0 -0
  41. phasepred/data/models/hSaPS/8f_model_5.joblib +0 -0
  42. phasepred/data/models/hSaPS/8f_model_6.joblib +0 -0
  43. phasepred/data/models/hSaPS/8f_model_7.joblib +0 -0
  44. phasepred/data/models/hSaPS/8f_model_8.joblib +0 -0
  45. phasepred/data/models/hSaPS/8f_model_9.joblib +0 -0
  46. phasepred/data/models/hSaPS/manifest.json +27 -0
  47. phasepred/data.py +65 -0
  48. phasepred/espritz.py +192 -0
  49. phasepred/features.py +158 -0
  50. phasepred/legacy_features.py +224 -0
  51. phasepred/models.py +141 -0
  52. phasepred/paper.py +160 -0
  53. phasepred/predictor.py +215 -0
  54. phasepred/sequences.py +220 -0
  55. phasepred/tool_features.py +218 -0
  56. phasepred/tool_paths.py +429 -0
  57. phasepred/tools.py +602 -0
  58. phasepred/updated_data.py +349 -0
  59. phasepred-0.9.0.dist-info/METADATA +283 -0
  60. phasepred-0.9.0.dist-info/RECORD +64 -0
  61. phasepred-0.9.0.dist-info/WHEEL +5 -0
  62. phasepred-0.9.0.dist-info/entry_points.txt +2 -0
  63. phasepred-0.9.0.dist-info/licenses/LICENSE +35 -0
  64. phasepred-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import subprocess
5
+ import tempfile
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import pandas as pd
11
+
12
+ from phasepred.data import data_path
13
+ from phasepred.features import compute_native_features
14
+ from phasepred.tool_paths import (
15
+ PhaSePredToolNotFound,
16
+ find_tool_entry,
17
+ )
18
+
19
+
20
+ class LegacyFeatureError(RuntimeError):
21
+ """Raised when a legacy feature adapter cannot compute a value."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ScalarFeature:
26
+ accession: str
27
+ value: float
28
+ source: str
29
+
30
+ def to_json(self) -> dict[str, object]:
31
+ return asdict(self)
32
+
33
+ @classmethod
34
+ def from_json(cls, payload: dict[str, Any]) -> ScalarFeature:
35
+ return cls(
36
+ accession=str(payload["accession"]),
37
+ value=float(payload["value"]),
38
+ source=str(payload["source"]),
39
+ )
40
+
41
+
42
+ def compute_lcr_features(sequence_rows: list[dict[str, object]]) -> list[ScalarFeature]:
43
+ try:
44
+ seg_entry = find_tool_entry("seg")
45
+ except PhaSePredToolNotFound as e:
46
+ raise LegacyFeatureError(str(e)) from e
47
+ fasta = _write_temp_fasta(sequence_rows)
48
+ try:
49
+ result = subprocess.run(
50
+ [str(seg_entry), str(fasta), "12", "2.2", "2.5", "-x"],
51
+ check=True,
52
+ capture_output=True,
53
+ text=True,
54
+ )
55
+ finally:
56
+ fasta.unlink(missing_ok=True)
57
+ masked = _parse_fasta_text(result.stdout)
58
+ features = []
59
+ for row in sequence_rows:
60
+ accession = str(row["UniprotEntry"])
61
+ sequence = masked.get(accession, "")
62
+ if not sequence:
63
+ continue
64
+ low_complexity = sum(1 for residue in sequence if residue == "x" or residue.islower())
65
+ features.append(
66
+ ScalarFeature(
67
+ accession=accession,
68
+ value=low_complexity / len(sequence),
69
+ source="seg_12_2.2_2.5_x",
70
+ )
71
+ )
72
+ return features
73
+
74
+
75
+ def compute_pscore_features(sequence_rows: list[dict[str, object]]) -> list[ScalarFeature]:
76
+ try:
77
+ pscore_entry = find_tool_entry("pscore")
78
+ except PhaSePredToolNotFound as e:
79
+ raise LegacyFeatureError(str(e)) from e
80
+ with tempfile.TemporaryDirectory(prefix="phasepred_pscore_") as tmp:
81
+ workdir = Path(tmp)
82
+ fasta = workdir / "input.fasta"
83
+ output = workdir / "pscore.tsv"
84
+ fasta.write_text(_fasta_text(sequence_rows), encoding="utf-8")
85
+ subprocess.run(
86
+ [
87
+ str(pscore_entry),
88
+ str(fasta),
89
+ "-output",
90
+ str(output),
91
+ "-overwrite",
92
+ "-mute",
93
+ ],
94
+ check=True,
95
+ capture_output=True,
96
+ text=True,
97
+ )
98
+ return parse_pscore_output(output)
99
+
100
+
101
+ def parse_pscore_output(path: str | Path) -> list[ScalarFeature]:
102
+ rows = []
103
+ for line in Path(path).read_text(encoding="utf-8", errors="replace").splitlines():
104
+ parts = line.split()
105
+ if len(parts) >= 3 and parts[0].startswith("PScore"):
106
+ accession = parts[-1].lstrip(">")
107
+ rows.append(ScalarFeature(accession, float(parts[1]), "pscore_elife_sourcecode2"))
108
+ return rows
109
+
110
+
111
+ def compute_plaac_features(sequence_rows: list[dict[str, object]]) -> list[ScalarFeature]:
112
+ try:
113
+ plaac_runner = find_tool_entry("plaac")
114
+ except PhaSePredToolNotFound as e:
115
+ raise LegacyFeatureError(str(e)) from e
116
+ with tempfile.TemporaryDirectory(prefix="phasepred_plaac_") as tmp:
117
+ fasta = Path(tmp) / "input.fasta"
118
+ fasta.write_text(_fasta_text(sequence_rows), encoding="utf-8")
119
+ result = subprocess.run(
120
+ [str(plaac_runner), "-i", str(fasta)],
121
+ cwd=str(data_path()),
122
+ check=True,
123
+ capture_output=True,
124
+ text=True,
125
+ )
126
+ return parse_plaac_output(result.stdout)
127
+
128
+
129
+ def parse_plaac_output(text: str) -> list[ScalarFeature]:
130
+ lines = [line for line in text.splitlines() if line.strip()]
131
+ header = None
132
+ rows = []
133
+ for line in lines:
134
+ if line.startswith("SEQid\t"):
135
+ header = line.split("\t")
136
+ continue
137
+ if header is None or line.startswith("#"):
138
+ continue
139
+ parts = line.split("\t")
140
+ if len(parts) != len(header):
141
+ continue
142
+ record = dict(zip(header, parts, strict=False))
143
+ if "SEQid" in record and "NLLR" in record:
144
+ rows.append(ScalarFeature(record["SEQid"], float(record["NLLR"]), "plaac_nllr"))
145
+ return rows
146
+
147
+
148
+ def compute_phosphosite_frequencies(
149
+ sequence_rows: list[dict[str, object]],
150
+ phosphosite_path: str | Path,
151
+ ) -> list[ScalarFeature]:
152
+ lengths = {str(row["UniprotEntry"]): len(str(row["sequence"])) for row in sequence_rows}
153
+ counts = dict.fromkeys(lengths, 0)
154
+ with gzip.open(phosphosite_path, "rt", encoding="utf-8", errors="replace") as handle:
155
+ header = None
156
+ for line in handle:
157
+ if line.startswith("GENE\t"):
158
+ header = line.rstrip("\n").split("\t")
159
+ continue
160
+ if header is None or not line.strip() or line.startswith("PhosphoSitePlus"):
161
+ continue
162
+ record = dict(zip(header, line.rstrip("\n").split("\t"), strict=False))
163
+ accession = record.get("ACC_ID", "")
164
+ if accession in counts and record.get("ORGANISM", "").lower() == "human":
165
+ counts[accession] += 1
166
+ return [
167
+ ScalarFeature(accession, count / lengths[accession], "phosphositeplus_sites_per_residue")
168
+ for accession, count in counts.items()
169
+ if lengths[accession] > 0
170
+ ]
171
+
172
+
173
+ def load_deepphase_scores(path: str | Path) -> pd.DataFrame:
174
+ frame = pd.read_excel(path, sheet_name="tableS3")
175
+ required = {"Swiss-Prot ID", "DeepPhase_score"}
176
+ missing = required - set(frame.columns)
177
+ if missing:
178
+ raise LegacyFeatureError(f"DeepPhase table missing columns: {', '.join(sorted(missing))}")
179
+ return frame[["Swiss-Prot ID", "DeepPhase_score"]].rename(
180
+ columns={"Swiss-Prot ID": "UniprotEntry", "DeepPhase_score": "DeepPhase"}
181
+ )
182
+
183
+
184
+ def native_feature_frame(sequence_rows: list[dict[str, object]]) -> pd.DataFrame:
185
+ rows = []
186
+ errors = []
187
+ for row in sequence_rows:
188
+ accession = str(row["UniprotEntry"])
189
+ try:
190
+ features = compute_native_features(str(row["sequence"]))
191
+ except ValueError as exc:
192
+ errors.append({"UniprotEntry": accession, "error": str(exc)})
193
+ features = {}
194
+ rows.append({"UniprotEntry": accession, **features})
195
+ frame = pd.DataFrame(rows)
196
+ if errors:
197
+ frame.attrs["errors"] = errors
198
+ return frame
199
+
200
+
201
+ def _write_temp_fasta(sequence_rows: list[dict[str, object]]) -> Path:
202
+ handle = tempfile.NamedTemporaryFile("w", suffix=".fasta", delete=False, encoding="utf-8")
203
+ with handle:
204
+ handle.write(_fasta_text(sequence_rows))
205
+ return Path(handle.name)
206
+
207
+
208
+ def _fasta_text(sequence_rows: list[dict[str, object]]) -> str:
209
+ return "".join(
210
+ f">{str(row['UniprotEntry']).strip()}\n{''.join(str(row['sequence']).split()).upper()}\n"
211
+ for row in sequence_rows
212
+ )
213
+
214
+
215
+ def _parse_fasta_text(text: str) -> dict[str, str]:
216
+ records: dict[str, list[str]] = {}
217
+ current = None
218
+ for line in text.splitlines():
219
+ if line.startswith(">"):
220
+ current = line[1:].split()[0]
221
+ records[current] = []
222
+ elif current is not None:
223
+ records[current].append(line.strip())
224
+ return {accession: "".join(parts) for accession, parts in records.items()}
phasepred/models.py ADDED
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import joblib
8
+ import pandas as pd
9
+ from sklearn.impute import SimpleImputer
10
+ from sklearn.linear_model import LogisticRegression
11
+ from sklearn.pipeline import Pipeline
12
+ from sklearn.preprocessing import StandardScaler
13
+
14
+ from phasepred.features import (
15
+ BASE_FEATURE_COLUMNS,
16
+ HUMAN_FEATURE_COLUMNS,
17
+ validate_feature_columns,
18
+ )
19
+
20
+ FEATURES_BY_TASK = {
21
+ "SaPS": BASE_FEATURE_COLUMNS,
22
+ "PdPS": BASE_FEATURE_COLUMNS,
23
+ "hSaPS": HUMAN_FEATURE_COLUMNS,
24
+ "hPdPS": HUMAN_FEATURE_COLUMNS,
25
+ }
26
+
27
+ # The v2022 feature-definitions version that the code implements
28
+ # (docs/FEATURES.md). Model artifacts tag their training definitions with
29
+ # this value; mismatches trigger the stale warning in `predict`.
30
+ FEATURE_DEFINITIONS_VERSION = "v2022"
31
+
32
+ MANIFEST_FILENAME = "manifest.json"
33
+
34
+
35
+ class ModelManifestError(RuntimeError):
36
+ """Raised when a model directory manifest is unreadable."""
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ModelManifest:
41
+ feature_definitions_version: str
42
+ raw: dict[str, Any]
43
+
44
+
45
+ def load_model_manifest(model_dir: Path) -> ModelManifest | None:
46
+ """Read ``manifest.json`` from a model directory, if present.
47
+
48
+ Returns ``None`` for pre-0.9.0 artifacts that carry no manifest. A
49
+ malformed manifest raises :class:`ModelManifestError`.
50
+ """
51
+ manifest_path = model_dir / MANIFEST_FILENAME
52
+ if not manifest_path.is_file():
53
+ return None
54
+ try:
55
+ import json
56
+
57
+ raw = json.loads(manifest_path.read_text(encoding="utf-8"))
58
+ except (OSError, ValueError) as exc:
59
+ raise ModelManifestError(f"Unreadable model manifest at {manifest_path}: {exc}") from exc
60
+ if not isinstance(raw, dict):
61
+ raise ModelManifestError(f"Model manifest at {manifest_path} is not a JSON object")
62
+ version = raw.get("feature_definitions_version")
63
+ if not isinstance(version, str) or not version:
64
+ raise ModelManifestError(
65
+ f"Model manifest at {manifest_path} is missing string "
66
+ "feature_definitions_version"
67
+ )
68
+ return ModelManifest(feature_definitions_version=version, raw=raw)
69
+
70
+
71
+ def model_dir_is_stale(model_dir: Path) -> tuple[bool, str]:
72
+ """Check whether a model directory binds an out-of-date feature set.
73
+
74
+ Returns ``(is_stale, reason)``. Legacy artifacts without a manifest, and
75
+ artifacts whose ``feature_definitions_version`` differs from the current
76
+ ``FEATURE_DEFINITIONS_VERSION``, are stale.
77
+ """
78
+ manifest = load_model_manifest(model_dir)
79
+ if manifest is None:
80
+ return True, (
81
+ f"model directory {model_dir} has no {MANIFEST_FILENAME} "
82
+ "(pre-0.9.0 artifact trained before feature_definitions_version "
83
+ f"tagging; current version {FEATURE_DEFINITIONS_VERSION})"
84
+ )
85
+ if manifest.feature_definitions_version != FEATURE_DEFINITIONS_VERSION:
86
+ return True, (
87
+ f"model directory {model_dir} binds feature_definitions_version "
88
+ f"{manifest.feature_definitions_version!r} but the code implements "
89
+ f"{FEATURE_DEFINITIONS_VERSION!r}"
90
+ )
91
+ return False, ""
92
+
93
+
94
+ @dataclass
95
+ class ModelArtifact:
96
+ task: str
97
+ feature_columns: list[str]
98
+ estimator: Pipeline
99
+
100
+ def dump(self, path: str | Path) -> None:
101
+ path = Path(path)
102
+ path.parent.mkdir(parents=True, exist_ok=True)
103
+ joblib.dump(self, path)
104
+
105
+
106
+ def train_model_artifact(
107
+ frame: pd.DataFrame,
108
+ *,
109
+ label_column: str,
110
+ task: str,
111
+ feature_columns: list[str] | None = None,
112
+ ) -> ModelArtifact:
113
+ if label_column not in frame.columns:
114
+ raise ValueError(f"Label column not found: {label_column}")
115
+ columns = feature_columns or list(FEATURES_BY_TASK[task])
116
+ validate_feature_columns(frame, columns)
117
+ estimator = Pipeline(
118
+ steps=[
119
+ ("impute", SimpleImputer(strategy="median")),
120
+ ("scale", StandardScaler()),
121
+ ("classifier", LogisticRegression(max_iter=2000, random_state=42)),
122
+ ]
123
+ )
124
+ estimator.fit(frame[columns], frame[label_column].astype(int))
125
+ return ModelArtifact(task=task, feature_columns=columns, estimator=estimator)
126
+
127
+
128
+ def load_model_artifact(path: str | Path) -> ModelArtifact:
129
+ artifact = joblib.load(path)
130
+ if not isinstance(artifact, ModelArtifact):
131
+ raise TypeError(f"Unexpected model artifact type: {type(artifact)!r}")
132
+ return artifact
133
+
134
+
135
+ def predict_with_artifact(artifact: ModelArtifact, frame: pd.DataFrame) -> pd.DataFrame:
136
+ validate_feature_columns(frame, artifact.feature_columns)
137
+ scores = artifact.estimator.predict_proba(frame[artifact.feature_columns])[:, 1]
138
+ output = pd.DataFrame({"score": scores})
139
+ if "UniprotEntry" in frame.columns:
140
+ output.insert(0, "UniprotEntry", frame["UniprotEntry"].to_numpy())
141
+ return output
phasepred/paper.py ADDED
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import pandas as pd
7
+
8
+
9
+ class PaperSplitError(ValueError):
10
+ """Raised when paper supplemental split tables cannot be parsed."""
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class PaperSplit:
15
+ train: pd.DataFrame
16
+ test: pd.DataFrame
17
+
18
+ @property
19
+ def all_rows(self) -> pd.DataFrame:
20
+ return pd.concat([self.train, self.test], ignore_index=True)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class PaperFeatures:
25
+ train: pd.DataFrame
26
+ test: pd.DataFrame
27
+
28
+ @property
29
+ def all_rows(self) -> pd.DataFrame:
30
+ return pd.concat([self.train, self.test], ignore_index=True)
31
+
32
+
33
+ TRAIN_SHEETS: dict[str, tuple[str, str]] = {
34
+ "SaPS": ("SaPS", "NoPS"),
35
+ "PdPS": ("PdPS", "NoPS"),
36
+ "hSaPS": ("hSaPS", "hNoPS"),
37
+ "hPdPS": ("hPdPS", "hNoPS"),
38
+ }
39
+
40
+ TEST_SHEETS: dict[str, tuple[tuple[str, ...], str]] = {
41
+ "SaPS": (("SaPS-test", "PS-test"), "NoPS-test"),
42
+ "PdPS": (("PdPS-test", "PS-test"), "NoPS-test"),
43
+ "hSaPS": (("hSaPS-test", "hPS-test"), "hNoPS-test"),
44
+ "hPdPS": (("hPdPS-test", "hPS-test"), "hNoPS-test"),
45
+ }
46
+
47
+
48
+ def load_paper_split(s2_path: str | Path, s3_path: str | Path) -> PaperSplit:
49
+ s2 = Path(s2_path)
50
+ s3 = Path(s3_path)
51
+ if not s2.exists():
52
+ raise PaperSplitError(f"Dataset S2 workbook not found: {s2}")
53
+ if not s3.exists():
54
+ raise PaperSplitError(f"Dataset S3 workbook not found: {s3}")
55
+
56
+ train_rows: list[dict[str, object]] = []
57
+ for task, (positive_sheet, negative_sheet) in TRAIN_SHEETS.items():
58
+ train_rows.extend(_rows_for_sheet(s2, positive_sheet, task, "train", 1))
59
+ train_rows.extend(_rows_for_sheet(s2, negative_sheet, task, "train", 0))
60
+
61
+ test_rows: list[dict[str, object]] = []
62
+ for task, (positive_sheets, negative_sheet) in TEST_SHEETS.items():
63
+ for positive_sheet in positive_sheets:
64
+ test_rows.extend(_rows_for_sheet(s3, positive_sheet, task, "test", 1))
65
+ test_rows.extend(_rows_for_sheet(s3, negative_sheet, task, "test", 0))
66
+
67
+ return PaperSplit(
68
+ train=pd.DataFrame(train_rows, columns=_split_columns()),
69
+ test=pd.DataFrame(test_rows, columns=_split_columns()),
70
+ )
71
+
72
+
73
+ def load_paper_features(s2_path: str | Path, s3_path: str | Path) -> PaperFeatures:
74
+ s2 = Path(s2_path)
75
+ s3 = Path(s3_path)
76
+ if not s2.exists():
77
+ raise PaperSplitError(f"Dataset S2 workbook not found: {s2}")
78
+ if not s3.exists():
79
+ raise PaperSplitError(f"Dataset S3 workbook not found: {s3}")
80
+
81
+ train_rows: list[dict[str, object]] = []
82
+ for task, (positive_sheet, negative_sheet) in TRAIN_SHEETS.items():
83
+ train_rows.extend(_feature_rows_for_sheet(s2, positive_sheet, task, "train", 1))
84
+ train_rows.extend(_feature_rows_for_sheet(s2, negative_sheet, task, "train", 0))
85
+
86
+ test_rows: list[dict[str, object]] = []
87
+ for task, (positive_sheets, negative_sheet) in TEST_SHEETS.items():
88
+ for positive_sheet in positive_sheets:
89
+ test_rows.extend(_feature_rows_for_sheet(s3, positive_sheet, task, "test", 1))
90
+ test_rows.extend(_feature_rows_for_sheet(s3, negative_sheet, task, "test", 0))
91
+
92
+ return PaperFeatures(
93
+ train=pd.DataFrame(train_rows),
94
+ test=pd.DataFrame(test_rows),
95
+ )
96
+
97
+
98
+ def _split_columns() -> list[str]:
99
+ return ["task", "split", "label", "UniprotEntry", "source_sheet"]
100
+
101
+
102
+ def _rows_for_sheet(
103
+ workbook: Path,
104
+ sheet_name: str,
105
+ task: str,
106
+ split: str,
107
+ label: int,
108
+ ) -> list[dict[str, object]]:
109
+ frame = _read_sheet(workbook, sheet_name)
110
+ return [
111
+ {
112
+ "task": task,
113
+ "split": split,
114
+ "label": label,
115
+ "UniprotEntry": accession,
116
+ "source_sheet": sheet_name,
117
+ }
118
+ for accession in (frame["UniprotEntry"].dropna().astype(str).str.strip().tolist())
119
+ if accession
120
+ ]
121
+
122
+
123
+ def _feature_rows_for_sheet(
124
+ workbook: Path,
125
+ sheet_name: str,
126
+ task: str,
127
+ split: str,
128
+ label: int,
129
+ ) -> list[dict[str, object]]:
130
+ frame = _read_sheet(workbook, sheet_name)
131
+ records = frame.to_dict(orient="records")
132
+ rows: list[dict[str, object]] = []
133
+ for record in records:
134
+ accession = str(record.get("UniprotEntry") or "").strip()
135
+ if not accession:
136
+ continue
137
+ row = dict(record)
138
+ row.update(
139
+ {
140
+ "task": task,
141
+ "split": split,
142
+ "label": label,
143
+ "UniprotEntry": accession,
144
+ "source_sheet": sheet_name,
145
+ }
146
+ )
147
+ rows.append(row)
148
+ return rows
149
+
150
+
151
+ def _read_sheet(workbook: Path, sheet_name: str) -> pd.DataFrame:
152
+ try:
153
+ frame = pd.read_excel(workbook, sheet_name=sheet_name)
154
+ except ValueError as exc:
155
+ raise PaperSplitError(f"Required sheet {sheet_name!r} not found in {workbook}") from exc
156
+ if "UniprotEntry" not in frame.columns:
157
+ frame = pd.read_excel(workbook, sheet_name=sheet_name, header=1)
158
+ if "UniprotEntry" not in frame.columns:
159
+ raise PaperSplitError(f"Sheet {sheet_name!r} in {workbook} lacks UniprotEntry column")
160
+ return frame