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.
Files changed (83) hide show
  1. salmopredict/__init__.py +30 -0
  2. salmopredict/cli.py +303 -0
  3. salmopredict/config.py +103 -0
  4. salmopredict/core/__init__.py +1 -0
  5. salmopredict/core/align.py +104 -0
  6. salmopredict/core/io_tables.py +76 -0
  7. salmopredict/core/modelinfo.py +71 -0
  8. salmopredict/core/predict.py +102 -0
  9. salmopredict/gui/__init__.py +1 -0
  10. salmopredict/gui/app.py +242 -0
  11. salmopredict/gui/assets/salmopredict_icon.png +0 -0
  12. salmopredict/gui/assets/vphs_logo.png +0 -0
  13. salmopredict/models/.DS_Store +0 -0
  14. salmopredict/models/model_default/learner.pkl +0 -0
  15. salmopredict/models/model_default/metadata.json +292 -0
  16. salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F1/model-internals.pkl +0 -0
  17. salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F1/model.pkl +0 -0
  18. salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F2/model-internals.pkl +0 -0
  19. salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F2/model.pkl +0 -0
  20. salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F3/model-internals.pkl +0 -0
  21. salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F3/model.pkl +0 -0
  22. salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F4/model-internals.pkl +0 -0
  23. salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F4/model.pkl +0 -0
  24. salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F5/model-internals.pkl +0 -0
  25. salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/S1F5/model.pkl +0 -0
  26. salmopredict/models/model_default/models/NeuralNetFastAI_r100_BAG_L1/model.pkl +0 -0
  27. salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F1/model-internals.pkl +0 -0
  28. salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F1/model.pkl +0 -0
  29. salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F2/model-internals.pkl +0 -0
  30. salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F2/model.pkl +0 -0
  31. salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F3/model-internals.pkl +0 -0
  32. salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F3/model.pkl +0 -0
  33. salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F4/model-internals.pkl +0 -0
  34. salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F4/model.pkl +0 -0
  35. salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F5/model-internals.pkl +0 -0
  36. salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/S1F5/model.pkl +0 -0
  37. salmopredict/models/model_default/models/NeuralNetFastAI_r134_BAG_L1/model.pkl +0 -0
  38. salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F1/model-internals.pkl +0 -0
  39. salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F1/model.pkl +0 -0
  40. salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F2/model-internals.pkl +0 -0
  41. salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F2/model.pkl +0 -0
  42. salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F3/model-internals.pkl +0 -0
  43. salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F3/model.pkl +0 -0
  44. salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F4/model-internals.pkl +0 -0
  45. salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F4/model.pkl +0 -0
  46. salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F5/model-internals.pkl +0 -0
  47. salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/S1F5/model.pkl +0 -0
  48. salmopredict/models/model_default/models/NeuralNetFastAI_r156_BAG_L1/model.pkl +0 -0
  49. salmopredict/models/model_default/models/NeuralNetTorch_r121_BAG_L1/S1F1/model.pkl +0 -0
  50. salmopredict/models/model_default/models/NeuralNetTorch_r121_BAG_L1/S1F2/model.pkl +0 -0
  51. salmopredict/models/model_default/models/NeuralNetTorch_r121_BAG_L1/S1F3/model.pkl +0 -0
  52. salmopredict/models/model_default/models/NeuralNetTorch_r121_BAG_L1/S1F4/model.pkl +0 -0
  53. salmopredict/models/model_default/models/NeuralNetTorch_r121_BAG_L1/S1F5/model.pkl +0 -0
  54. salmopredict/models/model_default/models/NeuralNetTorch_r121_BAG_L1/model.pkl +0 -0
  55. salmopredict/models/model_default/models/NeuralNetTorch_r1_BAG_L1/S1F1/model.pkl +0 -0
  56. salmopredict/models/model_default/models/NeuralNetTorch_r1_BAG_L1/S1F2/model.pkl +0 -0
  57. salmopredict/models/model_default/models/NeuralNetTorch_r1_BAG_L1/S1F3/model.pkl +0 -0
  58. salmopredict/models/model_default/models/NeuralNetTorch_r1_BAG_L1/S1F4/model.pkl +0 -0
  59. salmopredict/models/model_default/models/NeuralNetTorch_r1_BAG_L1/S1F5/model.pkl +0 -0
  60. salmopredict/models/model_default/models/NeuralNetTorch_r1_BAG_L1/model.pkl +0 -0
  61. salmopredict/models/model_default/models/NeuralNetTorch_r30_BAG_L1/S1F1/model.pkl +0 -0
  62. salmopredict/models/model_default/models/NeuralNetTorch_r30_BAG_L1/S1F2/model.pkl +0 -0
  63. salmopredict/models/model_default/models/NeuralNetTorch_r30_BAG_L1/S1F3/model.pkl +0 -0
  64. salmopredict/models/model_default/models/NeuralNetTorch_r30_BAG_L1/S1F4/model.pkl +0 -0
  65. salmopredict/models/model_default/models/NeuralNetTorch_r30_BAG_L1/S1F5/model.pkl +0 -0
  66. salmopredict/models/model_default/models/NeuralNetTorch_r30_BAG_L1/model.pkl +0 -0
  67. salmopredict/models/model_default/models/NeuralNetTorch_r79_BAG_L1/S1F1/model.pkl +0 -0
  68. salmopredict/models/model_default/models/NeuralNetTorch_r79_BAG_L1/S1F2/model.pkl +0 -0
  69. salmopredict/models/model_default/models/NeuralNetTorch_r79_BAG_L1/S1F3/model.pkl +0 -0
  70. salmopredict/models/model_default/models/NeuralNetTorch_r79_BAG_L1/S1F4/model.pkl +0 -0
  71. salmopredict/models/model_default/models/NeuralNetTorch_r79_BAG_L1/S1F5/model.pkl +0 -0
  72. salmopredict/models/model_default/models/NeuralNetTorch_r79_BAG_L1/model.pkl +0 -0
  73. salmopredict/models/model_default/models/WeightedEnsemble_L2/model.pkl +0 -0
  74. salmopredict/models/model_default/models/trainer.pkl +0 -0
  75. salmopredict/models/model_default/predictor.pkl +0 -0
  76. salmopredict/models/model_default/version.txt +1 -0
  77. salmopredict/pipeline.py +112 -0
  78. salmopredict-0.1.0.dist-info/METADATA +126 -0
  79. salmopredict-0.1.0.dist-info/RECORD +83 -0
  80. salmopredict-0.1.0.dist-info/WHEEL +5 -0
  81. salmopredict-0.1.0.dist-info/entry_points.txt +2 -0
  82. salmopredict-0.1.0.dist-info/licenses/LICENSE +202 -0
  83. salmopredict-0.1.0.dist-info/top_level.txt +1 -0
@@ -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")