amrpredict 0.3.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 (35) hide show
  1. amrpredict/__init__.py +259 -0
  2. amrpredict/_paths.py +11 -0
  3. amrpredict/amr_constants.py +202 -0
  4. amrpredict/cli.py +125 -0
  5. amrpredict/genome.py +496 -0
  6. amrpredict/lgbm.py +561 -0
  7. amrpredict/models/ab_rate_full.joblib +0 -0
  8. amrpredict/models/amr_lgbm_final_model.txt +10634 -0
  9. amrpredict/models/genome/feature_meta.json +536 -0
  10. amrpredict/models/genome/genome_metrics.json +52 -0
  11. amrpredict/models/genome/model.txt +10704 -0
  12. amrpredict/models/genome_genes/feature_meta.json +811 -0
  13. amrpredict/models/genome_genes/genome_metrics.json +52 -0
  14. amrpredict/models/genome_genes/model.txt +10961 -0
  15. amrpredict/models/genome_genes/species_profiles.npz +0 -0
  16. amrpredict/models/genus_ab_rate_full.joblib +0 -0
  17. amrpredict/models/lgbm_meta.joblib +0 -0
  18. amrpredict/models/lgbm_metrics.json +97 -0
  19. amrpredict/models/rl_agent/policy.npz +0 -0
  20. amrpredict/models/rl_agent/rl_agent.json +22 -0
  21. amrpredict/models/taxon_ab_rate_full.joblib +0 -0
  22. amrpredict/models/taxon_species.csv +13059 -0
  23. amrpredict/models/timeline_calibration.json +21 -0
  24. amrpredict/models/timeline_ml/model.txt +1683 -0
  25. amrpredict/models/timeline_ml/timeline_metrics.json +145 -0
  26. amrpredict/models/timeline_ml/timeline_ml.json +1 -0
  27. amrpredict/registry.py +67 -0
  28. amrpredict/rl_agent.py +177 -0
  29. amrpredict/timeline.py +275 -0
  30. amrpredict/timeline_ml.py +174 -0
  31. amrpredict-0.3.0.dist-info/METADATA +304 -0
  32. amrpredict-0.3.0.dist-info/RECORD +35 -0
  33. amrpredict-0.3.0.dist-info/WHEEL +4 -0
  34. amrpredict-0.3.0.dist-info/entry_points.txt +2 -0
  35. amrpredict-0.3.0.dist-info/licenses/LICENSE +21 -0
amrpredict/genome.py ADDED
@@ -0,0 +1,496 @@
1
+ """Genome models (complete genomes): the library's copy of the project's
2
+ backend/ml_models/genome_predictor.py, the code behind the web app's /predict.
3
+
4
+ Two models are bundled, each in its own folder:
5
+
6
+ genome/ k-mer model: LightGBM on k-mer frequencies. Needs nothing
7
+ beyond Python.
8
+ genome_genes/ gene model: LightGBM on the resistance genes and mutations
9
+ AMRFinderPlus finds in the upload (lab AUC 0.977 against
10
+ 0.935 for k-mers). Needs the `amrfinder` program and its
11
+ database on the server.
12
+
13
+ GenomeModelPredictor(model_dir) serves the gene model when it is promoted and
14
+ AMRFinderPlus can run here, and the k-mer model otherwise, so a server without
15
+ AMRFinderPlus (a Windows laptop) keeps working unchanged. If AMRFinderPlus fails
16
+ on one upload, that request is answered by the k-mer model, with a warning.
17
+
18
+ How the gene model builds its input, matching training exactly:
19
+ 1. species: the upload's 6-mer profile against the mean profile of each of
20
+ the 99 species in the training genomes (species_profiles.npz); right
21
+ species for 98.0% and right genus for 99.8% of held-out genomes
22
+ 2. AMRFinderPlus with --organism for that species (or genus) when it is on
23
+ AMRFinderPlus's list, as experiments/genome/features/run_amrfinder.py ran
24
+ it for training; without it no point mutations are reported
25
+ 3. hits with Scope = core and Type = AMR (build_gene_matrix.py), turned
26
+ into the 0/1 gene columns and the drug-aware counts of
27
+ experiments/genome/genes.py (rules stored in feature_meta.json)
28
+
29
+ Also, as in training: k-mers are counted inside each contig, ACGT only, exactly
30
+ as experiments/genome/kmers.py counted them (a test checks the two agree); the
31
+ whole genome is read (no 500 kb cut).
32
+
33
+ Artifacts (each folder):
34
+ model.txt LightGBM booster
35
+ feature_meta.json features, category levels, threshold, k, gene rules
36
+ genome_metrics.json metrics, format in progress/formats/README.md
37
+ species_profiles.npz gene model only: species reference profiles
38
+
39
+ Environment (gene model):
40
+ AMRFINDER_PATH the amrfinder binary, if it is not on PATH or in a
41
+ conda env called `amrfinder`
42
+ AMRFINDER_THREADS threads per run (default 4)
43
+ AMRFINDER_TIMEOUT seconds before giving up (default 100, under the
44
+ 120 s /predict waits)
45
+ AMRFINDER_DATABASE a database folder to use instead of the installed
46
+ one. Training used AMRFinderPlus 4.2.7 with database
47
+ 2026-08-07.1 (gene_summary.md); a newer database can
48
+ rename or add genes the model never saw
49
+ """
50
+ import json
51
+ import logging
52
+ import os
53
+ import shutil
54
+ import subprocess
55
+ import tempfile
56
+
57
+ import numpy as np
58
+ import pandas as pd
59
+
60
+ from ._paths import default_model_dir
61
+ from .amr_constants import DRUG_CLASS_MAP as TRAINING_DRUG_CLASS_MAP
62
+ from .amr_constants import normalize_antibiotic
63
+
64
+ _log = logging.getLogger(__name__)
65
+
66
+
67
+ def load_metrics(path):
68
+ """metrics.json written beside a model artifact, or None if absent."""
69
+ if not os.path.exists(path):
70
+ return None
71
+ try:
72
+ with open(path, encoding='utf-8') as fh:
73
+ return json.load(fh)
74
+ except (OSError, ValueError) as e:
75
+ _log.warning(f"Could not read {path}: {e}")
76
+ return None
77
+
78
+ K_MAX = 6
79
+ BASES = 'ACGT'
80
+ _LUT = np.full(256, 4, dtype=np.uint8)
81
+ for _i, _c in enumerate(BASES):
82
+ _LUT[ord(_c)] = _i
83
+ _LUT[ord(_c.lower())] = _i
84
+
85
+ KMER_FOLDER = 'genome'
86
+ GENE_FOLDER = 'genome_genes'
87
+ # Above this cosine distance to every species profile the species is called
88
+ # unknown (within a species 99% of genomes are under 0.003; between species
89
+ # the distance is at least 0.0068)
90
+ SPECIES_MAX_DISTANCE = 0.01
91
+ # Genera AMRFinderPlus covers as one organism (run_amrfinder.py GENUS_ALIASES)
92
+ GENUS_ALIASES = {'Shigella': 'Escherichia'}
93
+
94
+
95
+ def contigs(fasta_text):
96
+ """Each contig of FASTA text as bytes."""
97
+ chunks = []
98
+ for line in fasta_text.splitlines():
99
+ if line.startswith('>'):
100
+ if chunks:
101
+ yield ''.join(chunks).encode()
102
+ chunks = []
103
+ else:
104
+ chunks.append(line.strip())
105
+ if chunks:
106
+ yield ''.join(chunks).encode()
107
+
108
+
109
+ def count_6mers(codes):
110
+ """6-mer counts (4096,) of one contig given as 0..4 codes (4 = not ACGT)."""
111
+ n = len(codes) - K_MAX + 1
112
+ if n <= 0:
113
+ return np.zeros(4 ** K_MAX, dtype=np.int64)
114
+ c = codes.astype(np.int64)
115
+ csum = np.concatenate([[0], np.cumsum(codes == 4)])
116
+ ok = (csum[K_MAX:] - csum[:-K_MAX]) == 0
117
+ idx = np.zeros(n, dtype=np.int64)
118
+ for j in range(K_MAX):
119
+ idx = idx * 4 + np.minimum(c[j:j + n], 3)
120
+ return np.bincount(idx[ok], minlength=4 ** K_MAX)
121
+
122
+
123
+ def genome_counts(fasta_text):
124
+ """(6-mer counts, GC fraction, ACGT length) of a whole genome."""
125
+ counts = np.zeros(4 ** K_MAX, dtype=np.int64)
126
+ acgt = gc = 0
127
+ for raw in contigs(fasta_text):
128
+ codes = _LUT[np.frombuffer(raw, dtype=np.uint8)]
129
+ counts += count_6mers(codes)
130
+ acgt += int((codes < 4).sum())
131
+ gc += int(((codes == 1) | (codes == 2)).sum())
132
+ return counts, (gc / acgt if acgt else float('nan')), acgt
133
+
134
+
135
+ def kmer_frequencies(counts6, k=4):
136
+ ck = counts6.reshape((4,) * K_MAX).sum(axis=tuple(range(k, K_MAX))).reshape(-1).astype(np.float64)
137
+ total = ck.sum()
138
+ return ck / total if total else ck
139
+
140
+
141
+ def genome_features(fasta_text, k=4):
142
+ """(k-mer frequencies, GC fraction, ACGT length) of a whole genome."""
143
+ counts, gc, length = genome_counts(fasta_text)
144
+ return kmer_frequencies(counts, k), gc, length
145
+
146
+
147
+ def kmer_names(k):
148
+ names = ['']
149
+ for _ in range(k):
150
+ names = [n + b for n in names for b in BASES]
151
+ return [f'k{k}_{n}' for n in names]
152
+
153
+
154
+ # ── AMRFinderPlus ────────────────────────────────────────────────────────
155
+
156
+ def find_amrfinder():
157
+ """Path of the amrfinder binary, or None."""
158
+ home = os.path.expanduser('~')
159
+ candidates = [os.environ.get('AMRFINDER_PATH'), shutil.which('amrfinder')] + [
160
+ os.path.join(home, base, 'envs', 'amrfinder', 'bin', 'amrfinder')
161
+ for base in ('anaconda3', 'miniconda3', 'miniforge3')] + [
162
+ '/opt/anaconda3/envs/amrfinder/bin/amrfinder', '/opt/conda/envs/amrfinder/bin/amrfinder',
163
+ '/opt/conda/bin/amrfinder']
164
+ for path in candidates:
165
+ if path and os.path.isfile(path):
166
+ return path
167
+ return None
168
+
169
+
170
+ def amrfinder_env(amrfinder):
171
+ """Its folder first on PATH (it calls blast and hmmer) and CONDA_PREFIX set
172
+ to its env, which is where it looks for its database (run_amrfinder.py)."""
173
+ env = dict(os.environ)
174
+ env['PATH'] = os.path.dirname(amrfinder) + os.pathsep + env.get('PATH', '')
175
+ env.setdefault('CONDA_PREFIX', os.path.dirname(os.path.dirname(amrfinder)))
176
+ return env
177
+
178
+
179
+ _ORGANISMS = {}
180
+
181
+
182
+ def supported_organisms(amrfinder):
183
+ """AMRFinderPlus's --organism options (asked once per binary)."""
184
+ if amrfinder not in _ORGANISMS:
185
+ out = subprocess.run([amrfinder, '--list_organisms'], capture_output=True, text=True,
186
+ env=amrfinder_env(amrfinder), timeout=60)
187
+ line = next((l for l in (out.stdout + out.stderr).splitlines() if 'organism options' in l), '')
188
+ _ORGANISMS[amrfinder] = {o.strip() for o in line.split(':', 1)[1].split(',')} if ':' in line else set()
189
+ return _ORGANISMS[amrfinder]
190
+
191
+
192
+ def organism_for(species_name, genus_name, supported):
193
+ """The --organism value for a genome, or None (run_amrfinder.py)."""
194
+ if isinstance(species_name, str) and species_name.replace(' ', '_') in supported:
195
+ return species_name.replace(' ', '_')
196
+ genus = GENUS_ALIASES.get(genus_name, genus_name)
197
+ return genus if genus in supported else None
198
+
199
+
200
+ # AMRFinderPlus 3.x column names -> 4.x (the names build_gene_matrix.py read)
201
+ _OLD_COLUMNS = {'Gene symbol': 'Element symbol', 'Sequence name': 'Element name',
202
+ 'Element type': 'Type', 'Element subtype': 'Subtype'}
203
+
204
+
205
+ def parse_amrfinder(tsv_text, point_subtypes=('POINT', 'POINT_DISRUPT')):
206
+ """Core AMR hits of one AMRFinderPlus report: one row per symbol with
207
+ symbol, name, class, subclass, type ('gene' or 'point_mutation')."""
208
+ import io
209
+ df = pd.read_csv(io.StringIO(tsv_text), sep='\t', dtype=str).rename(columns=_OLD_COLUMNS)
210
+ df = df[(df['Scope'] == 'core') & (df['Type'] == 'AMR')].drop_duplicates('Element symbol')
211
+ return pd.DataFrame({
212
+ 'symbol': df['Element symbol'].to_numpy(),
213
+ 'name': df['Element name'].fillna('').to_numpy() if 'Element name' in df else '',
214
+ 'class': df['Class'].fillna('').to_numpy(),
215
+ 'subclass': df['Subclass'].fillna('').to_numpy(),
216
+ 'type': np.where(df['Subtype'].isin(point_subtypes), 'point_mutation', 'gene'),
217
+ })
218
+
219
+
220
+ def run_amrfinder(fasta_text, organism=None, amrfinder=None):
221
+ """AMRFinderPlus on one genome: (core AMR hits, report text). Raises
222
+ RuntimeError if it fails or times out."""
223
+ amrfinder = amrfinder or find_amrfinder()
224
+ if not amrfinder:
225
+ raise RuntimeError('AMRFinderPlus is not installed on this server')
226
+ threads = os.environ.get('AMRFINDER_THREADS', '4')
227
+ timeout = float(os.environ.get('AMRFINDER_TIMEOUT', '100'))
228
+ with tempfile.TemporaryDirectory() as tmp:
229
+ fna, out = os.path.join(tmp, 'upload.fna'), os.path.join(tmp, 'hits.tsv')
230
+ with open(fna, 'w') as fh:
231
+ fh.write(fasta_text)
232
+ cmd = [amrfinder, '-n', fna, '--threads', str(threads), '-o', out]
233
+ if organism:
234
+ cmd += ['--organism', organism]
235
+ if os.environ.get('AMRFINDER_DATABASE'):
236
+ cmd += ['--database', os.environ['AMRFINDER_DATABASE']]
237
+ try:
238
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout,
239
+ env=amrfinder_env(amrfinder))
240
+ except subprocess.TimeoutExpired:
241
+ raise RuntimeError(f'AMRFinderPlus took longer than {timeout:.0f} s')
242
+ if proc.returncode != 0 or not os.path.exists(out):
243
+ last = (proc.stderr.strip().splitlines() or ['no message'])[-1]
244
+ raise RuntimeError(f'AMRFinderPlus failed: {last}')
245
+ with open(out) as fh:
246
+ report = fh.read()
247
+ return parse_amrfinder(report), report
248
+
249
+
250
+ # ── Features from the hits (experiments/genome/genes.py, for one genome) ─
251
+
252
+ def amr_classes(cls):
253
+ return {c.strip() for c in str(cls).split('/') if c.strip()} if cls else set()
254
+
255
+
256
+ def gene_features(hits, antibiotic, drug_class, rules, features):
257
+ """The gene columns of one (genome, antibiotic) row, as genes.attach()
258
+ builds them: raw 0/1 `g_<symbol>` columns and the drug-aware counts."""
259
+ symbols = set(hits['symbol'])
260
+ row = {f: float(f[2:] in symbols) for f in features if f.startswith('g_')}
261
+ classes = [amr_classes(c) for c in hits['class']]
262
+ is_mut = (hits['type'] == 'point_mutation').to_numpy()
263
+ targets = set(rules['class_to_amrfinder'].get(drug_class, []))
264
+ covers = np.array([bool(c & targets) for c in classes], dtype=bool)
265
+ prefixes = rules['key_determinants'].get(antibiotic, [])
266
+ efflux = set(rules.get('efflux_classes', ['EFFLUX', 'MULTIDRUG']))
267
+ gene_n = float((covers & ~is_mut).sum())
268
+ mut_n = float((covers & is_mut).sum())
269
+ row.update({
270
+ 'gene_class_n': gene_n,
271
+ 'mutation_class_n': mut_n,
272
+ 'gene_class_any': float(gene_n + mut_n > 0),
273
+ 'key_determinant': float(any(s.startswith(p) for s in symbols for p in prefixes)),
274
+ 'amr_genes_total': float(len(symbols)),
275
+ 'efflux_n': float(sum(bool(c & efflux) for c in classes)),
276
+ })
277
+ return row
278
+
279
+
280
+ def genes_found(hits, drug_class, rules):
281
+ """`genes_found` for the response (progress/formats/README.md §2): every
282
+ core AMR gene and mutation, `relevant` when its class covers the drug."""
283
+ targets = set(rules['class_to_amrfinder'].get(drug_class, []))
284
+ out = []
285
+ for symbol, name, cls, subclass, kind in zip(hits['symbol'], hits['name'], hits['class'],
286
+ hits['subclass'], hits['type']):
287
+ out.append({'gene': symbol, 'drug_class': str(cls).lower(), 'type': kind,
288
+ 'relevant': bool(amr_classes(cls) & targets),
289
+ 'subclass': str(subclass).lower(), 'name': name})
290
+ return sorted(out, key=lambda g: (not g['relevant'], g['gene']))
291
+
292
+
293
+ class SpeciesProfiles:
294
+ """Nearest species by cosine distance of 6-mer profiles."""
295
+
296
+ def __init__(self, path):
297
+ z = np.load(path, allow_pickle=False)
298
+ self.species = z['species_name'].astype(str)
299
+ self.genus = z['genus_name'].astype(str)
300
+ p = z['profile'].astype(np.float64)
301
+ self.unit = p / np.linalg.norm(p, axis=1, keepdims=True)
302
+
303
+ def identify(self, counts6):
304
+ f = counts6.astype(np.float64)
305
+ norm = np.linalg.norm(f)
306
+ if not norm:
307
+ return None
308
+ d = 1 - self.unit @ (f / norm)
309
+ j = int(d.argmin())
310
+ if d[j] > SPECIES_MAX_DISTANCE:
311
+ return {'species': None, 'genus': None, 'distance': round(float(d[j]), 5)}
312
+ return {'species': self.species[j], 'genus': self.genus[j], 'distance': round(float(d[j]), 5)}
313
+
314
+
315
+ class GenomeModelPredictor:
316
+ FOLDER = KMER_FOLDER
317
+ MIN_BP = 100_000 # a complete bacterial genome is 1-12 Mb
318
+
319
+ def __init__(self, model_dir=None, folder=None):
320
+ self.model_dir = model_dir = str(model_dir) if model_dir else default_model_dir()
321
+ if folder is None:
322
+ folder = GENE_FOLDER if self.gene_model_servable(model_dir) else KMER_FOLDER
323
+ self.folder = folder
324
+ self.dir = os.path.join(model_dir, folder)
325
+ self.model = None
326
+ self.meta = {}
327
+ self.metrics = load_metrics(os.path.join(self.dir, 'genome_metrics.json'))
328
+ self.is_trained = False
329
+ self.uses_genes = False
330
+ self.amrfinder = None
331
+ self.species_profiles = None
332
+ self.fallback = None
333
+ self._load()
334
+
335
+ @staticmethod
336
+ def gene_model_servable(model_dir):
337
+ """A gene model is promoted and AMRFinderPlus can run here."""
338
+ return (os.path.exists(os.path.join(model_dir, GENE_FOLDER, 'model.txt'))
339
+ and find_amrfinder() is not None)
340
+
341
+ def _load(self):
342
+ path = os.path.join(self.dir, 'model.txt')
343
+ if not os.path.exists(path):
344
+ _log.info('No genome model in %s.', self.dir)
345
+ return
346
+ import lightgbm as lgb
347
+ self.model = lgb.Booster(model_file=path)
348
+ with open(os.path.join(self.dir, 'feature_meta.json'), encoding='utf-8') as fh:
349
+ self.meta = json.load(fh)
350
+ self.k = int(self.meta.get('kmer_k', 4))
351
+ self.features = self.meta['features']
352
+ self.levels = {c: {str(v).lower(): v for v in lv}
353
+ for c, lv in self.meta.get('category_levels', {}).items()}
354
+ self.uses_kmers = any(f.startswith(f'k{self.k}_') for f in self.features)
355
+ self.uses_genes = any(f.startswith('g_') or f in ('gene_class_n', 'key_determinant')
356
+ for f in self.features)
357
+ if self.uses_genes:
358
+ # Served only with AMRFinderPlus and the rules to build its features
359
+ self.amrfinder = find_amrfinder()
360
+ self.rules = self.meta.get('gene_features')
361
+ profiles = os.path.join(self.dir, 'species_profiles.npz')
362
+ if os.path.exists(profiles):
363
+ self.species_profiles = SpeciesProfiles(profiles)
364
+ self.is_trained = bool(self.amrfinder and self.rules)
365
+ if self.folder != KMER_FOLDER and os.path.exists(os.path.join(self.model_dir, KMER_FOLDER, 'model.txt')):
366
+ self.fallback = GenomeModelPredictor(self.model_dir, folder=KMER_FOLDER)
367
+ else:
368
+ self.is_trained = True
369
+ _log.info(f"Loaded {self.meta.get('run_id')} from {self.folder}/"
370
+ f"{' (AMRFinderPlus ' + (self.amrfinder or 'not found') + ')' if self.uses_genes else ''}.")
371
+
372
+ @property
373
+ def threshold(self):
374
+ return float(self.meta.get('threshold', 0.5))
375
+
376
+ def features_frame(self, fasta_text, antibiotic, genus=None, species=None, hits=None, counts=None):
377
+ """The model input for one genome and antibiotic, built as in training.
378
+ Gene models need `hits` (run_amrfinder); `counts` saves recounting."""
379
+ counts6, gc, length = counts if counts is not None else genome_counts(fasta_text)
380
+ row = dict(zip(kmer_names(self.k), kmer_frequencies(counts6, self.k)))
381
+ row['genome_gc'] = gc
382
+ row['genome_length_mb'] = length / 1e6
383
+ ab = normalize_antibiotic(antibiotic)
384
+ row['Antibiotic'] = ab
385
+ # The map the model was trained with, stored in its bundle
386
+ drug_class = self.meta.get('drug_class_map', TRAINING_DRUG_CLASS_MAP).get(ab, 'other')
387
+ row['drug_class'] = drug_class
388
+ row['genus'] = genus or 'unknown'
389
+ row['species'] = species or 'unknown'
390
+ if self.uses_genes:
391
+ row.update(gene_features(hits, ab, drug_class, self.rules, self.features))
392
+ X = pd.DataFrame([row])
393
+ for col, lv in self.levels.items():
394
+ if col in X:
395
+ # A value the model never saw (e.g. an unknown antibiotic) goes in
396
+ # as missing, as LightGBM treats it; pandas will refuse it as a
397
+ # value outside the categories
398
+ value = lv.get(str(X[col].iloc[0]).lower())
399
+ X[col] = pd.Categorical([value], categories=list(lv.values()))
400
+ return X[self.features], length
401
+
402
+ def detect_species(self, counts6):
403
+ return self.species_profiles.identify(counts6) if self.species_profiles else None
404
+
405
+ def predict(self, fasta_text, antibiotic, threshold=None, genus=None, species=None):
406
+ if not self.is_trained:
407
+ return {'error': 'no genome model is served' if self.model is None else
408
+ 'this genome model needs AMRFinderPlus, which the server does not run'}
409
+ counts = genome_counts(fasta_text)
410
+ length = counts[2]
411
+ if length < self.MIN_BP:
412
+ return {'error': f'genome too short ({length:,} bp): upload a complete assembly',
413
+ 'sequence_length': length}
414
+
415
+ hits = detected = None
416
+ organism = None
417
+ if self.uses_genes:
418
+ detected = self.detect_species(counts[0])
419
+ if detected and detected['species']:
420
+ try:
421
+ organism = organism_for(detected['species'], detected['genus'],
422
+ supported_organisms(self.amrfinder))
423
+ except (OSError, subprocess.SubprocessError):
424
+ organism = None
425
+ try:
426
+ hits, _ = run_amrfinder(fasta_text, organism, self.amrfinder)
427
+ except (RuntimeError, OSError) as e:
428
+ if self.fallback and self.fallback.is_trained:
429
+ result = self.fallback.predict(fasta_text, antibiotic, threshold, genus, species)
430
+ result['warning'] = f'{e}; answered by the k-mer model instead'
431
+ return result
432
+ return {'error': str(e)}
433
+
434
+ threshold = self.threshold if threshold is None else float(threshold)
435
+ X, length = self.features_frame(fasta_text, antibiotic, genus, species, hits=hits, counts=counts)
436
+ prob = float(self.model.predict(X)[0])
437
+ ab = normalize_antibiotic(antibiotic)
438
+ known = ab in self.levels.get('Antibiotic', {})
439
+ result = {
440
+ 'prediction': 'Resistant' if prob >= threshold else 'Susceptible',
441
+ 'probability': round(prob, 4),
442
+ 'confidence': round((prob if prob >= threshold else 1 - prob) * 100, 1),
443
+ 'antibiotic': ab,
444
+ 'antibiotic_known': known,
445
+ 'sequence_length': length,
446
+ 'gc_content': round(float(counts[1]) * 100, 2),
447
+ 'threshold': threshold,
448
+ 'model_used': self.model_label,
449
+ 'model_run': self.meta.get('run_id'),
450
+ }
451
+ if self.uses_kmers:
452
+ # Same fields as the old K-mer predictor, so /predict's page and
453
+ # charts work unchanged; a gene-only model sends none (the page
454
+ # then hides the k-mer cards)
455
+ kmer_cols = [c for c in self.features if c.startswith(f'k{self.k}_')]
456
+ top = X[kmer_cols].iloc[0].sort_values(ascending=False).head(10)
457
+ result['top_kmers'] = [{'kmer': c.split('_', 1)[1], 'frequency': round(float(v), 5)}
458
+ for c, v in top.items()]
459
+ if self.uses_genes:
460
+ drug_class = X['drug_class'].iloc[0] if 'drug_class' in X else \
461
+ self.meta.get('drug_class_map', TRAINING_DRUG_CLASS_MAP).get(ab, 'other')
462
+ result['genes_found'] = genes_found(hits, str(drug_class), self.rules)
463
+ result['species_detected'] = dict(detected or {}, amrfinder_organism=organism)
464
+ return result
465
+
466
+ @property
467
+ def model_label(self):
468
+ run = self.meta.get('run_id')
469
+ if self.uses_genes:
470
+ return f'LightGBM on AMRFinderPlus resistance genes ({run})'
471
+ return f'LightGBM on {self.k}-mers ({run})'
472
+
473
+ @property
474
+ def ab_list(self):
475
+ """Antibiotics the model was trained on (the /predict dropdown)."""
476
+ return sorted(self.levels.get('Antibiotic', {}).values()) if hasattr(self, 'levels') else []
477
+
478
+ @property
479
+ def status(self):
480
+ if self.uses_genes:
481
+ model_type = 'LightGBM on AMRFinderPlus resistance genes and mutations of the complete genome'
482
+ else:
483
+ model_type = f'LightGBM on {getattr(self, "k", 4)}-mer frequencies of the complete genome'
484
+ return {
485
+ 'trained': self.is_trained,
486
+ 'model_type': model_type,
487
+ 'description': 'Predicts resistance from a complete genome assembly (FASTA)',
488
+ 'run_id': self.meta.get('run_id'),
489
+ 'antibiotics_known': len(self.ab_list),
490
+ 'feature_dim': len(getattr(self, 'features', [])),
491
+ 'needs_amrfinder': self.uses_genes,
492
+ 'amrfinder': bool(self.amrfinder) if self.uses_genes else None,
493
+ 'searches_genes': self.uses_genes and self.is_trained,
494
+ 'default_threshold': self.threshold,
495
+ 'metrics': self.metrics,
496
+ }