salmopredict 0.1.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.
- salmopredict/__init__.py +30 -0
- salmopredict/cli.py +303 -0
- salmopredict/config.py +103 -0
- salmopredict/core/__init__.py +1 -0
- salmopredict/core/align.py +104 -0
- salmopredict/core/io_tables.py +76 -0
- salmopredict/core/modelinfo.py +71 -0
- salmopredict/core/predict.py +102 -0
- salmopredict/gui/__init__.py +1 -0
- salmopredict/gui/app.py +242 -0
- salmopredict/gui/assets/salmopredict_icon.png +0 -0
- salmopredict/gui/assets/vphs_logo.png +0 -0
- salmopredict/models/.DS_Store +0 -0
- salmopredict/models/model_default/learner.pkl +0 -0
- salmopredict/models/model_default/metadata.json +292 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F1/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F1/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F2/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F2/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F3/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F3/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F4/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F4/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F5/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F5/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F1/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F1/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F2/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F2/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F3/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F3/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F4/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F4/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F5/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F5/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F1/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F1/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F2/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F2/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F3/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F3/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F4/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F4/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F5/model-internals.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F5/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r121_BAG_L1/S1F1/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r121_BAG_L1/S1F2/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r121_BAG_L1/S1F3/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r121_BAG_L1/S1F4/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r121_BAG_L1/S1F5/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r121_BAG_L1/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r1_BAG_L1/S1F1/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r1_BAG_L1/S1F2/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r1_BAG_L1/S1F3/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r1_BAG_L1/S1F4/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r1_BAG_L1/S1F5/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r1_BAG_L1/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r30_BAG_L1/S1F1/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r30_BAG_L1/S1F2/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r30_BAG_L1/S1F3/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r30_BAG_L1/S1F4/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r30_BAG_L1/S1F5/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r30_BAG_L1/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r79_BAG_L1/S1F1/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r79_BAG_L1/S1F2/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r79_BAG_L1/S1F3/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r79_BAG_L1/S1F4/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r79_BAG_L1/S1F5/model.pkl +0 -0
- salmopredict/models/model_default/models/NeuralNetTorch_r79_BAG_L1/model.pkl +0 -0
- salmopredict/models/model_default/models/WeightedEnsemble_L2/model.pkl +0 -0
- salmopredict/models/model_default/models/trainer.pkl +0 -0
- salmopredict/models/model_default/predictor.pkl +0 -0
- salmopredict/models/model_default/version.txt +1 -0
- salmopredict/pipeline.py +112 -0
- salmopredict-0.1.0.dist-info/METADATA +126 -0
- salmopredict-0.1.0.dist-info/RECORD +83 -0
- salmopredict-0.1.0.dist-info/WHEEL +5 -0
- salmopredict-0.1.0.dist-info/entry_points.txt +2 -0
- salmopredict-0.1.0.dist-info/licenses/LICENSE +202 -0
- salmopredict-0.1.0.dist-info/top_level.txt +1 -0
salmopredict/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""salmopredict: predict Salmonella Incidence from virulence-factor gene features.
|
|
2
|
+
|
|
3
|
+
Given a feature table of Salmonella virulence-factor gene presence/frequency,
|
|
4
|
+
salmopredict:
|
|
5
|
+
|
|
6
|
+
1. Aligns the input columns to the features expected by a pre-trained AutoGluon
|
|
7
|
+
TabularPredictor (normalising R-``make.names``-style column names and filling
|
|
8
|
+
genes the model expects but the input lacks with zero).
|
|
9
|
+
2. Runs the ``WeightedEnsemble_L2`` model to predict the ``Incidence`` target.
|
|
10
|
+
3. Writes one prediction file (``Incidence(%)``, plus a ``Sample`` column and any
|
|
11
|
+
attached metadata when the input carries a ``Sample`` column).
|
|
12
|
+
|
|
13
|
+
The same core is shared by a command-line interface and a Streamlit GUI.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
# One-paragraph summary shown in both the CLI help and the GUI, so the two
|
|
19
|
+
# interfaces describe the tool with identical wording.
|
|
20
|
+
DESCRIPTION = (
|
|
21
|
+
"salmopredict predicts Salmonella incidence from virulence-factor "
|
|
22
|
+
"gene-frequency features and provides both command-line and graphical "
|
|
23
|
+
"interfaces. It standardizes the input feature table and outputs per-row "
|
|
24
|
+
"incidence predictions. salmopredict was developed at the State Key "
|
|
25
|
+
"Laboratory of Veterinary Public Health and Safety, China Agricultural "
|
|
26
|
+
"University, in collaboration with the China National Center for Food Safety "
|
|
27
|
+
"Risk Assessment (CFSA)."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = ["__version__", "DESCRIPTION"]
|
salmopredict/cli.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Command-line interface for salmopredict.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
salmopredict run -- predict Incidence for one or more feature CSVs
|
|
5
|
+
salmopredict gui -- launch the Streamlit graphical interface
|
|
6
|
+
salmopredict check -- verify dependencies and the model
|
|
7
|
+
salmopredict build-model -- clone a full AutoGluon model into a slim deploy copy
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import DESCRIPTION, __version__
|
|
19
|
+
from .config import (
|
|
20
|
+
DEFAULT_ENSEMBLE_MODEL,
|
|
21
|
+
DEFAULT_MISSING_WARN_FRACTION,
|
|
22
|
+
PREDICTION_MAX,
|
|
23
|
+
PREDICTION_MIN,
|
|
24
|
+
PredictConfig,
|
|
25
|
+
default_model_path,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Colorised --help. rich-argparse handles column alignment, NO_COLOR, and
|
|
29
|
+
# non-TTY detection for us; if it is not installed we fall back to plain help so
|
|
30
|
+
# the CLI keeps working uncoloured.
|
|
31
|
+
try:
|
|
32
|
+
from rich_argparse import RawDescriptionRichHelpFormatter as _HelpFormatter
|
|
33
|
+
|
|
34
|
+
# A tasteful, on-theme palette (salmon headings for a Salmonella tool).
|
|
35
|
+
_HelpFormatter.styles["argparse.prog"] = "bold #fa8072"
|
|
36
|
+
_HelpFormatter.styles["argparse.groups"] = "bold #fa8072"
|
|
37
|
+
_HelpFormatter.styles["argparse.args"] = "cyan"
|
|
38
|
+
_HelpFormatter.styles["argparse.metavar"] = "dim cyan"
|
|
39
|
+
_HelpFormatter.styles["argparse.help"] = "default"
|
|
40
|
+
except ImportError: # pragma: no cover - color is a nice-to-have, not required
|
|
41
|
+
from argparse import RawDescriptionHelpFormatter as _HelpFormatter
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _CliProgress:
|
|
45
|
+
"""Throttled, single-line progress reporter for the terminal."""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._stage = None
|
|
49
|
+
|
|
50
|
+
def __call__(self, stage: str, done: int, total: int, message: str) -> None:
|
|
51
|
+
if stage != self._stage:
|
|
52
|
+
if self._stage is not None:
|
|
53
|
+
sys.stderr.write("\n")
|
|
54
|
+
self._stage = stage
|
|
55
|
+
if total and total > 0:
|
|
56
|
+
pct = 100.0 * done / total
|
|
57
|
+
sys.stderr.write(f"\r[{stage}] {done}/{total} ({pct:5.1f}%) {message:<40.40}")
|
|
58
|
+
else:
|
|
59
|
+
sys.stderr.write(f"\r[{stage}] {message:<60.60}")
|
|
60
|
+
sys.stderr.flush()
|
|
61
|
+
if stage == "done":
|
|
62
|
+
sys.stderr.write("\n")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
66
|
+
parser = argparse.ArgumentParser(
|
|
67
|
+
prog="salmopredict",
|
|
68
|
+
description=DESCRIPTION,
|
|
69
|
+
formatter_class=_HelpFormatter,
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument("-V", "--version", action="version", version=f"salmopredict {__version__}")
|
|
72
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
73
|
+
|
|
74
|
+
default_model = default_model_path()
|
|
75
|
+
|
|
76
|
+
run = sub.add_parser("run", help="Predict Incidence for one feature CSV.",
|
|
77
|
+
formatter_class=_HelpFormatter)
|
|
78
|
+
run.add_argument("-i", "--input", required=True,
|
|
79
|
+
help="One feature CSV file. If it has a 'Sample' column, the "
|
|
80
|
+
"output carries Sample + the prediction; otherwise it is "
|
|
81
|
+
"the prediction column only.")
|
|
82
|
+
run.add_argument("-o", "--output", required=True,
|
|
83
|
+
help="Output directory (created if missing); the result is "
|
|
84
|
+
"written there as pred_<input-stem>.csv.")
|
|
85
|
+
run.add_argument("--model", default=str(default_model) if default_model else None,
|
|
86
|
+
help="AutoGluon model directory. Defaults to $SALMOPREDICT_MODEL "
|
|
87
|
+
"or the model bundled under the package.")
|
|
88
|
+
run.add_argument("--model-name", default=DEFAULT_ENSEMBLE_MODEL,
|
|
89
|
+
help=f"Ensemble sub-model to predict with "
|
|
90
|
+
f"(default {DEFAULT_ENSEMBLE_MODEL}; falls back to the "
|
|
91
|
+
f"model's best if absent).")
|
|
92
|
+
run.add_argument("--missing-warn-frac", type=float, default=DEFAULT_MISSING_WARN_FRACTION,
|
|
93
|
+
help=f"Warn if more than this fraction of model features are "
|
|
94
|
+
f"missing from the input (default {DEFAULT_MISSING_WARN_FRACTION}).")
|
|
95
|
+
run.add_argument("--fill-value", type=float, default=0.0,
|
|
96
|
+
help="Value for features the model expects but the input lacks "
|
|
97
|
+
"(default 0.0 = gene absent).")
|
|
98
|
+
run.add_argument("--attach",
|
|
99
|
+
help="Metadata CSV joined on the 'Sample' key. Requires the "
|
|
100
|
+
"input to have a 'Sample' column; the metadata file must "
|
|
101
|
+
"have one too. Its other columns are appended to the output.")
|
|
102
|
+
run.add_argument("-f", "--force", action="store_true",
|
|
103
|
+
help="Allow writing into a non-empty output directory.")
|
|
104
|
+
|
|
105
|
+
gui = sub.add_parser("gui", help="Launch the Streamlit GUI.",
|
|
106
|
+
formatter_class=_HelpFormatter)
|
|
107
|
+
gui.add_argument("--port", type=int, default=8501, help="Port (default 8501).")
|
|
108
|
+
|
|
109
|
+
check = sub.add_parser("check", help="Check dependencies and the model.",
|
|
110
|
+
formatter_class=_HelpFormatter)
|
|
111
|
+
check.add_argument("--model", default=str(default_model) if default_model else None,
|
|
112
|
+
help="Model directory to verify (default: bundled/env model).")
|
|
113
|
+
|
|
114
|
+
build = sub.add_parser("build-model",
|
|
115
|
+
help="Clone a full AutoGluon model into a slim deploy copy.",
|
|
116
|
+
formatter_class=_HelpFormatter)
|
|
117
|
+
build.add_argument("--source", required=True,
|
|
118
|
+
help="Source AutoGluon model directory (may contain a literal '*').")
|
|
119
|
+
build.add_argument("-o", "--output", required=True,
|
|
120
|
+
help="Destination directory for the slim clone (must not be "
|
|
121
|
+
"inside --source).")
|
|
122
|
+
build.add_argument("--model-name", default=DEFAULT_ENSEMBLE_MODEL,
|
|
123
|
+
help=f"Model to keep in the clone (default {DEFAULT_ENSEMBLE_MODEL}).")
|
|
124
|
+
build.add_argument("-f", "--force", action="store_true",
|
|
125
|
+
help="Allow writing into an existing destination directory.")
|
|
126
|
+
|
|
127
|
+
return parser
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
131
|
+
config = PredictConfig(
|
|
132
|
+
input_path=Path(args.input),
|
|
133
|
+
output_dir=Path(args.output),
|
|
134
|
+
model_path=Path(args.model) if args.model else None,
|
|
135
|
+
ensemble_model=args.model_name,
|
|
136
|
+
fill_value=args.fill_value,
|
|
137
|
+
missing_warn_fraction=args.missing_warn_frac,
|
|
138
|
+
attach_metadata=Path(args.attach) if args.attach else None,
|
|
139
|
+
force=args.force,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Imported here so 'check'/'gui' work even if autogluon is absent.
|
|
143
|
+
from .pipeline import run_pipeline
|
|
144
|
+
|
|
145
|
+
result = run_pipeline(config, progress=_CliProgress())
|
|
146
|
+
|
|
147
|
+
cols = ", ".join(result.predictions.columns)
|
|
148
|
+
print(f"\nDone. label={result.label} model={result.model_used}")
|
|
149
|
+
print(f" {result.input_path.name}: {result.n_rows} rows -> {result.output_path}")
|
|
150
|
+
print(f" Sample column: {'yes' if result.has_sample else 'no'}"
|
|
151
|
+
+ (" (metadata attached)" if result.attached else ""))
|
|
152
|
+
print(f" output columns: {cols}")
|
|
153
|
+
n_imp = len(result.imputed)
|
|
154
|
+
print(f" imputed (filled {config.fill_value:g}): {n_imp} feature(s)"
|
|
155
|
+
+ (f" [{', '.join(result.imputed)}]" if 0 < n_imp <= 12 else ""))
|
|
156
|
+
print(f" missing fraction: {result.missing_fraction:.1%}")
|
|
157
|
+
if result.floored or result.capped:
|
|
158
|
+
print(f" clamped to [{PREDICTION_MIN:g}, {PREDICTION_MAX:g}]: "
|
|
159
|
+
f"{result.capped} capped at {PREDICTION_MAX:g}, "
|
|
160
|
+
f"{result.floored} floored at {PREDICTION_MIN:g}")
|
|
161
|
+
if result.collisions:
|
|
162
|
+
print(f" WARNING: {len(result.collisions)} normalised column name "
|
|
163
|
+
f"collision(s): {', '.join(result.collisions)}")
|
|
164
|
+
if result.warned:
|
|
165
|
+
print(f" WARNING: {result.missing_fraction:.1%} of model features were "
|
|
166
|
+
f"missing and filled with {config.fill_value:g} -- check that this "
|
|
167
|
+
f"is the right input for this model.")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _silence_streamlit_onboarding() -> None:
|
|
172
|
+
"""Skip Streamlit's first-run email prompt and telemetry.
|
|
173
|
+
|
|
174
|
+
Streamlit asks for an email on first launch unless a credentials file
|
|
175
|
+
exists; we create an anonymous one (empty email) so users never see that
|
|
176
|
+
prompt. Nothing is sent anywhere.
|
|
177
|
+
"""
|
|
178
|
+
creds = Path.home() / ".streamlit" / "credentials.toml"
|
|
179
|
+
if not creds.exists():
|
|
180
|
+
try:
|
|
181
|
+
creds.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
creds.write_text('[general]\nemail = ""\n')
|
|
183
|
+
except OSError:
|
|
184
|
+
pass # non-fatal; the user can still press Enter at the prompt
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _cmd_gui(args: argparse.Namespace) -> int:
|
|
188
|
+
app = Path(__file__).resolve().parent / "gui" / "app.py"
|
|
189
|
+
_silence_streamlit_onboarding()
|
|
190
|
+
env = os.environ.copy()
|
|
191
|
+
env.setdefault("STREAMLIT_BROWSER_GATHER_USAGE_STATS", "false")
|
|
192
|
+
cmd = [sys.executable, "-m", "streamlit", "run", str(app),
|
|
193
|
+
"--server.port", str(args.port),
|
|
194
|
+
"--browser.gatherUsageStats", "false"]
|
|
195
|
+
try:
|
|
196
|
+
return subprocess.call(cmd, env=env)
|
|
197
|
+
except FileNotFoundError:
|
|
198
|
+
print("streamlit is not installed. Install it with: pip install streamlit",
|
|
199
|
+
file=sys.stderr)
|
|
200
|
+
return 1
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
204
|
+
from .core import modelinfo
|
|
205
|
+
|
|
206
|
+
ok = True
|
|
207
|
+
|
|
208
|
+
ag_version = modelinfo.installed_autogluon_version()
|
|
209
|
+
ag_ok = ag_version == "1.1.1"
|
|
210
|
+
print(f" autogluon.tabular: {'OK ' + ag_version if ag_version else 'MISSING'}"
|
|
211
|
+
+ ("" if ag_ok or not ag_version else " (WARNING: model was trained with 1.1.1)"))
|
|
212
|
+
ok = ok and bool(ag_version)
|
|
213
|
+
|
|
214
|
+
for mod in ("torch", "streamlit", "pandas"):
|
|
215
|
+
try:
|
|
216
|
+
m = __import__(mod)
|
|
217
|
+
print(f" {mod}: OK {getattr(m, '__version__', '')}")
|
|
218
|
+
except Exception:
|
|
219
|
+
print(f" {mod}: MISSING")
|
|
220
|
+
if mod != "streamlit": # streamlit only needed for the GUI
|
|
221
|
+
ok = False
|
|
222
|
+
|
|
223
|
+
model_path = Path(args.model) if args.model else None
|
|
224
|
+
if model_path is None:
|
|
225
|
+
print(" model: MISSING (no --model, $SALMOPREDICT_MODEL, or bundled model)")
|
|
226
|
+
ok = False
|
|
227
|
+
else:
|
|
228
|
+
info = modelinfo.read_model_info(model_path)
|
|
229
|
+
if not info.exists:
|
|
230
|
+
print(f" model: MISSING ({model_path})")
|
|
231
|
+
ok = False
|
|
232
|
+
else:
|
|
233
|
+
print(f" model: OK ({model_path})")
|
|
234
|
+
print(f" version.txt: {info.version_txt} metadata.version: {info.meta_version}"
|
|
235
|
+
f" best: {info.model_best}")
|
|
236
|
+
if ag_version and info.version_txt and info.version_txt != ag_version:
|
|
237
|
+
print(f" WARNING: model autogluon {info.version_txt} != installed "
|
|
238
|
+
f"{ag_version}; unpickling may fail.")
|
|
239
|
+
rt = modelinfo.runtime_py_version()
|
|
240
|
+
if info.py_version and not rt.startswith(info.py_version.rsplit(".", 1)[0]):
|
|
241
|
+
print(f" WARNING: model trained on Python {info.py_version}, "
|
|
242
|
+
f"running {rt}.")
|
|
243
|
+
# Confirm the predictor actually loads.
|
|
244
|
+
try:
|
|
245
|
+
from .core.predict import load_predictor
|
|
246
|
+
p = load_predictor(model_path)
|
|
247
|
+
has_l2 = DEFAULT_ENSEMBLE_MODEL in p.model_names()
|
|
248
|
+
print(f" load: OK label={p.label} {DEFAULT_ENSEMBLE_MODEL}: "
|
|
249
|
+
f"{'present' if has_l2 else 'absent (will fall back to best)'}")
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
print(f" load: FAILED -- {exc}")
|
|
252
|
+
ok = False
|
|
253
|
+
|
|
254
|
+
return 0 if ok else 1
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _cmd_build_model(args: argparse.Namespace) -> int:
|
|
258
|
+
from .core.predict import load_predictor
|
|
259
|
+
|
|
260
|
+
source = Path(args.source)
|
|
261
|
+
dest = Path(args.output)
|
|
262
|
+
if dest.resolve() == source.resolve() or source.resolve() in dest.resolve().parents:
|
|
263
|
+
raise ValueError("--output must not be the source directory or inside it.")
|
|
264
|
+
|
|
265
|
+
print(f"Loading source model: {source}")
|
|
266
|
+
predictor = load_predictor(source)
|
|
267
|
+
if args.model_name not in predictor.model_names():
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"Model '{args.model_name}' not found in source. Available include: "
|
|
270
|
+
f"{', '.join(predictor.model_names()[:10])} ..."
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
print(f"Cloning for deployment (keeping {args.model_name}) -> {dest}")
|
|
274
|
+
predictor.clone_for_deployment(
|
|
275
|
+
path=str(dest),
|
|
276
|
+
model=args.model_name,
|
|
277
|
+
dirs_exist_ok=args.force,
|
|
278
|
+
)
|
|
279
|
+
print(f"Done. Slim model written to: {dest}")
|
|
280
|
+
print("Tip: point --model / $SALMOPREDICT_MODEL at this directory, or place it "
|
|
281
|
+
"under the package 'models/' folder.")
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def main(argv: list[str] | None = None) -> int:
|
|
286
|
+
args = build_parser().parse_args(argv)
|
|
287
|
+
try:
|
|
288
|
+
if args.command == "run":
|
|
289
|
+
return _cmd_run(args)
|
|
290
|
+
if args.command == "gui":
|
|
291
|
+
return _cmd_gui(args)
|
|
292
|
+
if args.command == "check":
|
|
293
|
+
return _cmd_check(args)
|
|
294
|
+
if args.command == "build-model":
|
|
295
|
+
return _cmd_build_model(args)
|
|
296
|
+
except (FileNotFoundError, ValueError, RuntimeError) as exc:
|
|
297
|
+
print(f"\nError: {exc}", file=sys.stderr)
|
|
298
|
+
return 1
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
if __name__ == "__main__":
|
|
303
|
+
sys.exit(main())
|
salmopredict/config.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Run configuration for salmopredict.
|
|
2
|
+
|
|
3
|
+
A single :class:`PredictConfig` object carries every parameter through the
|
|
4
|
+
pipeline so the CLI and the Streamlit GUI share exactly the same core.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
# Ensemble model used for prediction. The bundled model's overall best is
|
|
15
|
+
# WeightedEnsemble_L3, but the established workflow uses L2, so we default to L2
|
|
16
|
+
# for result parity. Falls back to predictor.model_best if L2 is absent.
|
|
17
|
+
DEFAULT_ENSEMBLE_MODEL = "WeightedEnsemble_L2"
|
|
18
|
+
|
|
19
|
+
# When more than this fraction of the model's features are missing from the
|
|
20
|
+
# input (and therefore filled with ``fill_value``), a prominent warning is
|
|
21
|
+
# emitted -- the prediction still runs.
|
|
22
|
+
DEFAULT_MISSING_WARN_FRACTION = 0.30
|
|
23
|
+
|
|
24
|
+
# Environment variable that can point at a default model directory.
|
|
25
|
+
MODEL_ENV_VAR = "SALMOPREDICT_MODEL"
|
|
26
|
+
|
|
27
|
+
# The one identifier column salmopredict recognises. When present in the input
|
|
28
|
+
# it is carried into the output and used to join any attached metadata; when
|
|
29
|
+
# absent the output holds the prediction column only.
|
|
30
|
+
SAMPLE_COLUMN = "Sample"
|
|
31
|
+
|
|
32
|
+
# Name of the single prediction column written to the output.
|
|
33
|
+
PREDICTION_COLUMN = "Incidence(%)"
|
|
34
|
+
|
|
35
|
+
# Incidence is a percentage, so predictions are clamped to this range. The
|
|
36
|
+
# regression model can occasionally output values slightly outside [0, 100],
|
|
37
|
+
# which are not physically meaningful; clamping keeps every output valid.
|
|
38
|
+
PREDICTION_MIN = 0.0
|
|
39
|
+
PREDICTION_MAX = 100.0
|
|
40
|
+
|
|
41
|
+
# Directory inside the package where a slimmed deployment model may be shipped.
|
|
42
|
+
_PKG_MODELS_DIR = Path(__file__).resolve().parent / "models"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def default_model_path() -> Optional[Path]:
|
|
46
|
+
"""Locate a default AutoGluon model directory.
|
|
47
|
+
|
|
48
|
+
Resolution order:
|
|
49
|
+
1. ``$SALMOPREDICT_MODEL`` if it points at an existing directory.
|
|
50
|
+
2. The single sub-directory of the package ``models/`` folder, if exactly
|
|
51
|
+
one exists (the slimmed model shipped via ``build-model``).
|
|
52
|
+
Returns ``None`` when neither is available, in which case ``--model`` must
|
|
53
|
+
be given explicitly.
|
|
54
|
+
"""
|
|
55
|
+
env = os.environ.get(MODEL_ENV_VAR)
|
|
56
|
+
if env:
|
|
57
|
+
p = Path(env).expanduser()
|
|
58
|
+
if p.is_dir():
|
|
59
|
+
return p
|
|
60
|
+
if _PKG_MODELS_DIR.is_dir():
|
|
61
|
+
subdirs = [d for d in sorted(_PKG_MODELS_DIR.iterdir()) if d.is_dir()]
|
|
62
|
+
if len(subdirs) == 1:
|
|
63
|
+
return subdirs[0]
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class PredictConfig:
|
|
69
|
+
"""All parameters for one salmopredict run.
|
|
70
|
+
|
|
71
|
+
One input feature CSV is aligned to the model and predicted, producing one
|
|
72
|
+
output file ``pred_<input-stem>.csv`` in ``output_dir``.
|
|
73
|
+
|
|
74
|
+
Output columns depend on the input:
|
|
75
|
+
* no ``Sample`` column -> just the prediction (``Incidence(%)``);
|
|
76
|
+
* a ``Sample`` column -> ``Sample`` + ``Incidence(%)``, and, if
|
|
77
|
+
``attach_metadata`` is given, the metadata columns (joined on ``Sample``)
|
|
78
|
+
are appended. Attaching metadata requires a ``Sample`` column.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
input_path: Path
|
|
82
|
+
output_dir: Path
|
|
83
|
+
model_path: Optional[Path] = field(default_factory=default_model_path)
|
|
84
|
+
ensemble_model: str = DEFAULT_ENSEMBLE_MODEL
|
|
85
|
+
fill_value: float = 0.0
|
|
86
|
+
missing_warn_fraction: float = DEFAULT_MISSING_WARN_FRACTION
|
|
87
|
+
|
|
88
|
+
# Optional external metadata CSV joined on the ``Sample`` key.
|
|
89
|
+
attach_metadata: Optional[Path] = None
|
|
90
|
+
|
|
91
|
+
force: bool = False
|
|
92
|
+
|
|
93
|
+
def __post_init__(self) -> None:
|
|
94
|
+
self.input_path = Path(self.input_path)
|
|
95
|
+
self.output_dir = Path(self.output_dir)
|
|
96
|
+
if self.model_path is not None:
|
|
97
|
+
self.model_path = Path(self.model_path)
|
|
98
|
+
if self.attach_metadata is not None:
|
|
99
|
+
self.attach_metadata = Path(self.attach_metadata)
|
|
100
|
+
if not 0.0 <= float(self.missing_warn_fraction) <= 1.0:
|
|
101
|
+
raise ValueError("missing_warn_fraction must be between 0 and 1")
|
|
102
|
+
self.missing_warn_fraction = float(self.missing_warn_fraction)
|
|
103
|
+
self.fill_value = float(self.fill_value)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Computation core for salmopredict (interface-independent)."""
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Align an input feature table to an AutoGluon model's expected features.
|
|
2
|
+
|
|
3
|
+
The bundled model was trained on USA data whose column names were normalised
|
|
4
|
+
R-``make.names``-style (``/`` and ``-`` become ``.``), and the input feature
|
|
5
|
+
tables carry many extra gene columns the model does not use plus a couple of
|
|
6
|
+
genes the model expects but the panel lacks. This module reproduces the exact
|
|
7
|
+
alignment used by the original ``predict_autogluon.py``:
|
|
8
|
+
|
|
9
|
+
* normalise column names with :func:`san` (mimicking ``make.names``),
|
|
10
|
+
* build ``X`` column-by-column in the model's feature order,
|
|
11
|
+
* fill features the model expects but the input lacks with ``fill_value`` (a
|
|
12
|
+
missing gene means frequency 0, i.e. a weighted feature of 0),
|
|
13
|
+
* ignore extra input columns.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import List
|
|
21
|
+
|
|
22
|
+
import pandas as pd
|
|
23
|
+
|
|
24
|
+
# Non-[0-9A-Za-z_] -> '.', matching R make.names and predict_autogluon.py's san().
|
|
25
|
+
_SAN_RE = re.compile(r"[^0-9A-Za-z_]")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def san(name: str) -> str:
|
|
29
|
+
"""Normalise a column name the way R ``make.names`` does for our data."""
|
|
30
|
+
return _SAN_RE.sub(".", name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class AlignmentResult:
|
|
35
|
+
"""Outcome of aligning one input table to the model features."""
|
|
36
|
+
|
|
37
|
+
X: pd.DataFrame # columns == model features, in model order
|
|
38
|
+
model_features: List[str]
|
|
39
|
+
imputed: List[str] # model features absent from input -> filled
|
|
40
|
+
extra_ignored: List[str] # input columns not used by any feature
|
|
41
|
+
collisions: List[str] = field(default_factory=list) # san() name clashes
|
|
42
|
+
n_rows: int = 0
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def missing_fraction(self) -> float:
|
|
46
|
+
if not self.model_features:
|
|
47
|
+
return 0.0
|
|
48
|
+
return len(self.imputed) / len(self.model_features)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def align_features(df: pd.DataFrame, predictor, *, fill_value: float = 0.0) -> AlignmentResult:
|
|
52
|
+
"""Build the model input ``X`` from an arbitrary feature table.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
df: Input feature table (rows = samples, columns = gene features).
|
|
56
|
+
predictor: A loaded AutoGluon ``TabularPredictor``.
|
|
57
|
+
fill_value: Value for features the model expects but the input lacks.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
An :class:`AlignmentResult`. Exact column-name matches take precedence
|
|
61
|
+
over ``san``-normalised matches; anything still unmatched is imputed.
|
|
62
|
+
"""
|
|
63
|
+
feats = list(predictor.feature_metadata_in.get_features())
|
|
64
|
+
feat_san = {san(f) for f in feats}
|
|
65
|
+
|
|
66
|
+
exact = set(df.columns)
|
|
67
|
+
san_map: dict[str, str] = {} # normalised input name -> original input name
|
|
68
|
+
collisions: List[str] = []
|
|
69
|
+
for col in df.columns:
|
|
70
|
+
s = san(col)
|
|
71
|
+
if s in san_map and s in feat_san:
|
|
72
|
+
# Two input columns normalise to the same model-relevant name.
|
|
73
|
+
collisions.append(s)
|
|
74
|
+
san_map[s] = col
|
|
75
|
+
|
|
76
|
+
# Build every column first, then construct X in one shot: assigning column
|
|
77
|
+
# by column into a growing DataFrame fragments it and is slow for 100+ cols.
|
|
78
|
+
data: dict = {}
|
|
79
|
+
imputed: List[str] = []
|
|
80
|
+
used_original: set = set()
|
|
81
|
+
for f in feats:
|
|
82
|
+
if f in exact:
|
|
83
|
+
data[f] = df[f].to_numpy()
|
|
84
|
+
used_original.add(f)
|
|
85
|
+
elif f in san_map:
|
|
86
|
+
src = san_map[f]
|
|
87
|
+
data[f] = df[src].to_numpy()
|
|
88
|
+
used_original.add(src)
|
|
89
|
+
else:
|
|
90
|
+
data[f] = fill_value # scalar broadcasts across the index below
|
|
91
|
+
imputed.append(f)
|
|
92
|
+
|
|
93
|
+
X = pd.DataFrame(data, index=df.index, columns=feats)
|
|
94
|
+
|
|
95
|
+
extra_ignored = [c for c in df.columns if c not in used_original]
|
|
96
|
+
|
|
97
|
+
return AlignmentResult(
|
|
98
|
+
X=X,
|
|
99
|
+
model_features=feats,
|
|
100
|
+
imputed=imputed,
|
|
101
|
+
extra_ignored=extra_ignored,
|
|
102
|
+
collisions=sorted(set(collisions)),
|
|
103
|
+
n_rows=len(df),
|
|
104
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Input reading and output-table assembly.
|
|
2
|
+
|
|
3
|
+
Output contract (see :class:`~salmopredict.config.PredictConfig`):
|
|
4
|
+
|
|
5
|
+
* If the input has **no** ``Sample`` column, the output is a single
|
|
6
|
+
``Incidence(%)`` column -- features only, no metadata.
|
|
7
|
+
* If the input **has** a ``Sample`` column, the output is
|
|
8
|
+
``Sample`` + ``Incidence(%)``. When a metadata CSV is attached (it must also
|
|
9
|
+
have a ``Sample`` column), its remaining columns are joined on ``Sample`` and
|
|
10
|
+
appended: ``Sample`` + ``Incidence(%)`` + metadata columns.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
from ..config import PREDICTION_COLUMN, SAMPLE_COLUMN, PredictConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_features(path: Path) -> pd.DataFrame:
|
|
24
|
+
"""Read a feature CSV (UTF-8, header row of feature names)."""
|
|
25
|
+
return pd.read_csv(path)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_sample_column(df: pd.DataFrame) -> Optional[str]:
|
|
29
|
+
"""Return the input's ``Sample`` column name (case-insensitive), or None."""
|
|
30
|
+
for col in df.columns:
|
|
31
|
+
if str(col).strip().lower() == SAMPLE_COLUMN.lower():
|
|
32
|
+
return col
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_output(
|
|
37
|
+
df: pd.DataFrame,
|
|
38
|
+
preds: pd.Series,
|
|
39
|
+
config: PredictConfig,
|
|
40
|
+
) -> pd.DataFrame:
|
|
41
|
+
"""Assemble the output table according to the two-type contract above."""
|
|
42
|
+
sample_col = find_sample_column(df)
|
|
43
|
+
|
|
44
|
+
out = pd.DataFrame(index=df.index)
|
|
45
|
+
if sample_col is not None:
|
|
46
|
+
out[SAMPLE_COLUMN] = df[sample_col].to_numpy()
|
|
47
|
+
out[PREDICTION_COLUMN] = preds.to_numpy()
|
|
48
|
+
|
|
49
|
+
if config.attach_metadata is not None:
|
|
50
|
+
if sample_col is None:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"Attaching metadata requires a 'Sample' column in the input; "
|
|
53
|
+
"this input has none."
|
|
54
|
+
)
|
|
55
|
+
meta = pd.read_csv(config.attach_metadata)
|
|
56
|
+
meta_sample = find_sample_column(meta)
|
|
57
|
+
if meta_sample is None:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
"The metadata file must have a 'Sample' column to join on."
|
|
60
|
+
)
|
|
61
|
+
if meta[meta_sample].duplicated().any():
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"The metadata file has duplicate 'Sample' values; cannot join "
|
|
64
|
+
"unambiguously."
|
|
65
|
+
)
|
|
66
|
+
# Normalise both keys to SAMPLE_COLUMN, then left-join so every input row
|
|
67
|
+
# is kept in order; unmatched samples get blank metadata.
|
|
68
|
+
meta = meta.rename(columns={meta_sample: SAMPLE_COLUMN})
|
|
69
|
+
out = out.merge(meta, on=SAMPLE_COLUMN, how="left")
|
|
70
|
+
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def write_output(out_df: pd.DataFrame, path: Path) -> None:
|
|
75
|
+
"""Write the output table as UTF-8 CSV without the row index."""
|
|
76
|
+
out_df.to_csv(path, index=False, encoding="utf-8")
|