gpse 0.0.2__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 (67) hide show
  1. gpse/__init__.py +11 -0
  2. gpse/batch/__init__.py +3 -0
  3. gpse/batch/cli.py +72 -0
  4. gpse/batch/runner.py +206 -0
  5. gpse/cli.py +215 -0
  6. gpse/config/__init__.py +15 -0
  7. gpse/config/_topsis_config.py +205 -0
  8. gpse/config/constants.py +127 -0
  9. gpse/config/default.yaml +15 -0
  10. gpse/config/software.yaml +20 -0
  11. gpse/config/topsis.yaml +18 -0
  12. gpse/convert/__init__.py +11 -0
  13. gpse/convert/cli.py +65 -0
  14. gpse/convert/external.py +400 -0
  15. gpse/convert/genotype_matrix.py +349 -0
  16. gpse/convert/phenotype.py +409 -0
  17. gpse/convert/processor.py +815 -0
  18. gpse/convert/qc.py +424 -0
  19. gpse/convert/validators.py +181 -0
  20. gpse/convert/workflow.py +214 -0
  21. gpse/models/__init__.py +30 -0
  22. gpse/models/classification_model_optimizer.py +393 -0
  23. gpse/models/classification_models.py +8 -0
  24. gpse/models/model_optimizers.py +14 -0
  25. gpse/models/regression_model_optimizer.py +679 -0
  26. gpse/predict/__init__.py +5 -0
  27. gpse/predict/__main__.py +3 -0
  28. gpse/predict/cli.py +95 -0
  29. gpse/predict/core.py +315 -0
  30. gpse/tasks/__init__.py +12 -0
  31. gpse/tasks/classification.py +565 -0
  32. gpse/tools/__init__.py +1 -0
  33. gpse/tools/analyze_phenotypes.py +140 -0
  34. gpse/train/__init__.py +26 -0
  35. gpse/train/_cv_manager.py +234 -0
  36. gpse/train/_data_io.py +261 -0
  37. gpse/train/_ensemble.py +235 -0
  38. gpse/train/_feature_selection.py +287 -0
  39. gpse/train/_fold_training.py +304 -0
  40. gpse/train/_model_pipeline.py +125 -0
  41. gpse/train/_model_tools.py +97 -0
  42. gpse/train/_optimization.py +244 -0
  43. gpse/train/_pipeline.py +490 -0
  44. gpse/train/_repeat_training.py +665 -0
  45. gpse/train/_results.py +262 -0
  46. gpse/train/cli.py +408 -0
  47. gpse/train/predictor.py +305 -0
  48. gpse/train/stacking.py +230 -0
  49. gpse/train/topsis.py +214 -0
  50. gpse/train/workflow.py +416 -0
  51. gpse/utils/__init__.py +54 -0
  52. gpse/utils/cli_display.py +529 -0
  53. gpse/utils/configuration.py +192 -0
  54. gpse/utils/dependency_checker.py +280 -0
  55. gpse/utils/feature_manifest.py +63 -0
  56. gpse/utils/genomic_utils.py +812 -0
  57. gpse/utils/log_utils.py +569 -0
  58. gpse/utils/logo.py +235 -0
  59. gpse/utils/paralle.py +266 -0
  60. gpse/utils/print_utils.py +75 -0
  61. gpse/utils/snp_ids.py +61 -0
  62. gpse/utils/version.py +159 -0
  63. gpse-0.0.2.dist-info/METADATA +1123 -0
  64. gpse-0.0.2.dist-info/RECORD +67 -0
  65. gpse-0.0.2.dist-info/WHEEL +4 -0
  66. gpse-0.0.2.dist-info/entry_points.txt +3 -0
  67. gpse-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Shared helpers for resolving and invoking external tools in GPSE convert.
5
+
6
+ Provides configuration-driven discovery of third-party executables (PLINK,
7
+ Java, Beagle, etc.), version checks, and safe command execution.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import shlex
15
+ import subprocess
16
+ from pathlib import Path
17
+ from typing import Optional, Sequence
18
+
19
+ from gpse.utils.configuration import load_software_config
20
+ from gpse.utils.dependency_checker import check_all_external_tools, check_external_tool
21
+
22
+
23
+ def ensure_log_dir(output_prefix: str) -> str:
24
+ """Return a dedicated ``log/`` subdirectory next to the output prefix.
25
+
26
+ Creates the directory if it does not exist.
27
+ """
28
+ base_dir = os.path.dirname(output_prefix) or "."
29
+ log_dir = os.path.join(base_dir, "log")
30
+ os.makedirs(log_dir, exist_ok=True)
31
+ return log_dir
32
+
33
+
34
+ def _load_external_tools(
35
+ config_path: Optional[str] = None,
36
+ *,
37
+ auto_project_config: bool = False,
38
+ ) -> list[dict]:
39
+ """Load external tool definitions from gpse/config/software.yaml."""
40
+ return load_software_config(
41
+ config_path,
42
+ auto_project_config=auto_project_config,
43
+ ).get("external_tools", [])
44
+
45
+
46
+ def get_external_tool_config(
47
+ name: str,
48
+ *,
49
+ config_path: Optional[str] = None,
50
+ auto_project_config: bool = False,
51
+ ) -> dict:
52
+ """Return a configured external tool by name, or a minimal fallback."""
53
+ for tool in _load_external_tools(
54
+ config_path,
55
+ auto_project_config=auto_project_config,
56
+ ):
57
+ if tool.get("name") == name:
58
+ return dict(tool)
59
+ return {"name": name, "cmd": name, "version_flag": "--version", "required": True}
60
+
61
+
62
+ def check_configured_external_tools(
63
+ command_overrides: Optional[dict[str, str]] = None,
64
+ *,
65
+ config_path: Optional[str] = None,
66
+ auto_project_config: bool = False,
67
+ ) -> list[dict]:
68
+ """Check every external tool defined in software.yaml."""
69
+ command_overrides = command_overrides or {}
70
+ tools = []
71
+ for tool in _load_external_tools(
72
+ config_path,
73
+ auto_project_config=auto_project_config,
74
+ ):
75
+ configured = dict(tool)
76
+ override = command_overrides.get(configured.get("name"))
77
+ if override:
78
+ configured["cmd"] = override
79
+ tools.append(configured)
80
+ return check_all_external_tools(tools)
81
+
82
+
83
+ def get_convert_config(
84
+ config_path: Optional[str] = None,
85
+ *,
86
+ auto_project_config: bool = False,
87
+ ) -> dict:
88
+ """Load the optional 'convert' section from merged GPSE YAML config."""
89
+ convert_config = load_software_config(
90
+ config_path,
91
+ auto_project_config=auto_project_config,
92
+ ).get("convert") or {}
93
+ if not isinstance(convert_config, dict):
94
+ raise TypeError("'convert' config section must be a mapping")
95
+ return convert_config
96
+
97
+
98
+ def resolve_external_tool(
99
+ tool_path: str,
100
+ *,
101
+ name: Optional[str] = None,
102
+ version_flag: str = "--version",
103
+ min_version: Optional[str] = None,
104
+ ) -> str:
105
+ """Resolve an executable path and optionally validate its version."""
106
+ if not tool_path:
107
+ raise FileNotFoundError("External tool path is empty")
108
+
109
+ result = check_external_tool(
110
+ name=name or Path(tool_path).name,
111
+ cmd=tool_path,
112
+ version_flag=version_flag,
113
+ min_version=min_version,
114
+ )
115
+ if not result["available"]:
116
+ raise FileNotFoundError(f"External tool not found: {tool_path}")
117
+ if not result.get("version_ok", True):
118
+ raise RuntimeError(
119
+ f"External tool version too low: {result['name']} "
120
+ f"{result.get('version')} < required {result.get('min_version')}"
121
+ )
122
+ return result["path"] or tool_path
123
+
124
+
125
+ def resolve_configured_tool(
126
+ name: str,
127
+ command_override: Optional[str] = None,
128
+ *,
129
+ config_path: Optional[str] = None,
130
+ auto_project_config: bool = False,
131
+ ) -> str:
132
+ """Resolve a tool using software.yaml defaults, with an optional command override."""
133
+ tool = get_external_tool_config(
134
+ name,
135
+ config_path=config_path,
136
+ auto_project_config=auto_project_config,
137
+ )
138
+ return resolve_external_tool(
139
+ command_override or tool.get("cmd") or name,
140
+ name=tool.get("name", name),
141
+ version_flag=tool.get("version_flag", "--version"),
142
+ min_version=tool.get("min_version"),
143
+ )
144
+
145
+
146
+ def ensure_existing_file(file_path: str, *, name: str = "file") -> str:
147
+ """Return *file_path* if it points to an existing regular file."""
148
+ if not file_path:
149
+ raise FileNotFoundError(f"{name} path is empty")
150
+ path = Path(file_path)
151
+ if not path.is_file():
152
+ raise FileNotFoundError(f"{name} not found: {file_path}")
153
+ return str(path)
154
+
155
+
156
+ def _compress_stdout(lines: list[str]) -> list[str]:
157
+ """Compress high-frequency progress spam from external tools (e.g. PLINK).
158
+
159
+ Rules:
160
+ 1. ``--vcf: Nk variants complete.`` — keep every 10 k plus the first.
161
+ 2. Glued-together VCF progress lines — drop entirely.
162
+ 3. Lines with many percentages + "done" — clean and keep prefix.
163
+ 4. Standalone ``N%`` fragments — drop entirely.
164
+ 5. Lines with many inline percentages — strip the percentage run, keep the prefix.
165
+ """
166
+ out: list[str] = []
167
+ vcf_seen = 0
168
+
169
+ for raw in lines:
170
+ line = raw.strip()
171
+ if not line:
172
+ continue
173
+
174
+ # 1. VCF milestone filter
175
+ m = re.match(r"^--vcf:\s+(\d+)k variants complete\.$", line)
176
+ if m:
177
+ vcf_seen += 1
178
+ n = int(m.group(1))
179
+ if n == 1 or n % 10 == 0:
180
+ out.append(line)
181
+ continue
182
+
183
+ # 2. Drop glued-together VCF progress lines (multiple --vcf: messages in one line)
184
+ if "--vcf:" in line:
185
+ continue
186
+
187
+ # 3. Strip inline progress-bar completion with "done" (with or without space, with or without dot)
188
+ if re.search(r"(?:\d+%){3,}.*done\.?$", line):
189
+ cleaned = re.sub(r"\s*(?:\d+%){3,}.*done\.?$", "", line).strip()
190
+ if cleaned:
191
+ out.append(cleaned)
192
+ continue
193
+
194
+ # 4. Drop \r-split progress fragments (e.g. standalone "1%", "2%" ...).
195
+ if re.fullmatch(r"\d+%", line):
196
+ continue
197
+
198
+ # 5. Strip many inline percentages (PLINK progress bars without "done").
199
+ # Keep the meaningful prefix.
200
+ if re.search(r"(?:\d+%){3,}", line):
201
+ cleaned = re.sub(r"\s*(?:\d+%){3,}.*", "", line).strip()
202
+ if cleaned:
203
+ out.append(cleaned)
204
+ continue
205
+
206
+ out.append(line)
207
+
208
+ # If any VCF lines were filtered, append a summary so the user still knows
209
+ # the total scale of the job.
210
+ if vcf_seen:
211
+ kept = sum(1 for l in out if l.startswith("--vcf:"))
212
+ if kept < vcf_seen:
213
+ out.append(f"(VCF progress: {vcf_seen} milestones total, {kept} shown)")
214
+
215
+ return out
216
+
217
+
218
+ def _is_plink_cmd(cmd_args: list[str]) -> bool:
219
+ """Return True if the command looks like a PLINK invocation."""
220
+ if not cmd_args:
221
+ return False
222
+ return "plink" in os.path.basename(cmd_args[0]).lower()
223
+
224
+
225
+ def _extract_plink_out_prefix(cmd_args: list[str]) -> str | None:
226
+ """Extract the --out prefix from a PLINK command line."""
227
+ try:
228
+ out_idx = cmd_args.index("--out")
229
+ return cmd_args[out_idx + 1]
230
+ except (ValueError, IndexError):
231
+ return None
232
+
233
+
234
+ def _log_bfile_stats(prefix: str, label: str, logger) -> None:
235
+ """Log variant and sample counts for a PLINK binary prefix.
236
+
237
+ Numbers are highlighted with ANSI bold-cyan so the Rich sink renders
238
+ them prominently.
239
+ """
240
+ if not prefix:
241
+ return
242
+ bim_path = prefix + ".bim"
243
+ fam_path = prefix + ".fam"
244
+ if not os.path.isfile(bim_path) or not os.path.isfile(fam_path):
245
+ return
246
+
247
+ try:
248
+ with open(bim_path, "r") as f:
249
+ n_variants = sum(1 for _ in f)
250
+ with open(fam_path, "r") as f:
251
+ n_samples = sum(1 for _ in f)
252
+ except OSError:
253
+ return
254
+
255
+ cyan = "\033[1;36m"
256
+ reset = "\033[0m"
257
+ logger.info(
258
+ f"{label} — Variants: {cyan}{n_variants:,}{reset}, "
259
+ f"Samples: {cyan}{n_samples:,}{reset}"
260
+ )
261
+
262
+
263
+ def _plink_chr_error_hint(cmd_args: list[str], logger) -> None:
264
+ """When a PLINK command fails, check its log for chromosome-name errors
265
+ and suggest ``--allow-extra-chr`` if applicable."""
266
+ if not _is_plink_cmd(cmd_args) or "--allow-extra-chr" in cmd_args:
267
+ return
268
+
269
+ # Look for PLINK's auto-generated log next to the --out prefix.
270
+ try:
271
+ out_idx = cmd_args.index("--out")
272
+ log_path = cmd_args[out_idx + 1] + ".log"
273
+ except (ValueError, IndexError):
274
+ return
275
+
276
+ if not os.path.isfile(log_path):
277
+ return
278
+
279
+ try:
280
+ log_text = Path(log_path).read_text(encoding="utf-8", errors="replace")
281
+ except OSError:
282
+ return
283
+
284
+ # PLINK prints this exact phrase when it encounters non-standard chroms.
285
+ if "Allow-extra-chr" in log_text or "allow-extra-chr" in log_text:
286
+ logger.error(
287
+ "PLINK rejected non-standard chromosome names in this VCF. "
288
+ "Re-run with --allow-extra-chr to accept them."
289
+ )
290
+
291
+
292
+ def _plink_qc_error_hint(exc: subprocess.CalledProcessError, logger) -> None:
293
+ """Check if a PLINK command failed because all samples or SNPs were removed
294
+ by missingness filters (exit code 11 or 1)."""
295
+ if not _is_plink_cmd(exc.cmd):
296
+ return
297
+
298
+ # Exit code 11 often means "All people removed" or "All variants removed"
299
+ # during --mind/--geno filtering.
300
+ if exc.returncode in (1, 11):
301
+ try:
302
+ # Check for --mind or --geno in the command line
303
+ has_miss_filter = "--mind" in exc.cmd or "--geno" in exc.cmd
304
+ if has_miss_filter:
305
+ logger.error(
306
+ "QC FAILURE: All samples or variants were removed by PLINK filters. "
307
+ "This usually means the initial missingness rate is higher than "
308
+ "the thresholds specified by --snpmaxmiss or --samplemaxmiss. "
309
+ "TIP: Try relaxing the thresholds (e.g., set them to 0.5 or 1.0) "
310
+ "or enable --impute to fill missing data first."
311
+ )
312
+ except Exception:
313
+ pass
314
+
315
+
316
+ def _beagle_qc_error_hint(exc: subprocess.CalledProcessError, logger) -> None:
317
+ """Check if a Beagle command failed due to missing alleles or format issues."""
318
+ if not exc.cmd or "java" not in os.path.basename(str(exc.cmd[0])).lower() or "beagle" not in " ".join(str(c).lower() for c in exc.cmd):
319
+ return
320
+
321
+ if exc.returncode != 0:
322
+ logger.error(
323
+ "IMPUTATION FAILURE: Beagle imputation failed. "
324
+ "This can happen if the input data has too many missing variants, "
325
+ "invalid REF/ALT alleles (e.g., indels or structural variants not supported by Beagle), "
326
+ "or severe format issues. "
327
+ "TIP: Try running without --impute first, but relax your --snpmaxmiss and --samplemaxmiss "
328
+ "thresholds to see if the raw data can be processed without imputation."
329
+ )
330
+
331
+
332
+ def run_command(
333
+ cmd_list: Sequence[object],
334
+ *,
335
+ log_file: Optional[str] = None,
336
+ logger=None,
337
+ ) -> None:
338
+ """Run an external command without invoking a shell."""
339
+ cmd_args = [
340
+ os.fspath(arg) if isinstance(arg, (Path, os.PathLike)) else str(arg)
341
+ for arg in cmd_list
342
+ ]
343
+ cmd_text = shlex.join(cmd_args)
344
+ if logger is not None:
345
+ logger.info(f"Executing: {cmd_text}")
346
+
347
+ if log_file:
348
+ try:
349
+ with open(log_file, "a") as f:
350
+ subprocess.run(cmd_args, stdout=f, stderr=f, check=True)
351
+ except subprocess.CalledProcessError as exc:
352
+ if logger is not None:
353
+ _plink_chr_error_hint(cmd_args, logger)
354
+ _plink_qc_error_hint(exc, logger)
355
+ _beagle_qc_error_hint(exc, logger)
356
+ raise
357
+ if logger is not None and _is_plink_cmd(cmd_args):
358
+ out_prefix = _extract_plink_out_prefix(cmd_args)
359
+ if out_prefix:
360
+ _log_bfile_stats(out_prefix, "PLINK", logger)
361
+ else:
362
+ # When a logger is available but no dedicated log file is requested,
363
+ # capture the subprocess output and stream it through the logger so
364
+ # that external tool messages (e.g. PLINK) are tidy and unified with
365
+ # GPSE's own log format instead of being dumped raw to the terminal.
366
+ if logger is not None:
367
+ try:
368
+ result = subprocess.run(
369
+ cmd_args, capture_output=True, check=True
370
+ )
371
+ except subprocess.CalledProcessError as exc:
372
+ # Print whatever output PLINK produced before the error.
373
+ if exc.stdout:
374
+ stdout_text = exc.stdout.decode("utf-8", errors="replace").replace("\r", "")
375
+ for line in _compress_stdout(re.split(r"[\r\n]+", stdout_text.strip())):
376
+ logger.info(f"[stdout] {line}")
377
+ if exc.stderr:
378
+ stderr_text = exc.stderr.decode("utf-8", errors="replace").replace("\r", "")
379
+ for line in _compress_stdout(re.split(r"[\r\n]+", stderr_text.strip())):
380
+ logger.warning(f"[stderr] {line}")
381
+ _plink_chr_error_hint(cmd_args, logger)
382
+ _plink_qc_error_hint(exc, logger)
383
+ _beagle_qc_error_hint(exc, logger)
384
+ raise
385
+ if result.stdout:
386
+ stdout_text = result.stdout.decode('utf-8', errors='replace').replace('\r', '')
387
+ raw_lines = re.split(r'[\r\n]+', stdout_text.strip())
388
+ for line in _compress_stdout(raw_lines):
389
+ logger.info(f"[stdout] {line}")
390
+ if result.stderr:
391
+ stderr_text = result.stderr.decode('utf-8', errors='replace').replace('\r', '')
392
+ raw_err_lines = re.split(r'[\r\n]+', stderr_text.strip())
393
+ for line in _compress_stdout(raw_err_lines):
394
+ logger.warning(f"[stderr] {line}")
395
+ if _is_plink_cmd(cmd_args):
396
+ out_prefix = _extract_plink_out_prefix(cmd_args)
397
+ if out_prefix:
398
+ _log_bfile_stats(out_prefix, "PLINK", logger)
399
+ else:
400
+ subprocess.run(cmd_args, check=True)