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
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()