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.
- amrpredict/__init__.py +259 -0
- amrpredict/_paths.py +11 -0
- amrpredict/amr_constants.py +202 -0
- amrpredict/cli.py +125 -0
- amrpredict/genome.py +496 -0
- amrpredict/lgbm.py +561 -0
- amrpredict/models/ab_rate_full.joblib +0 -0
- amrpredict/models/amr_lgbm_final_model.txt +10634 -0
- amrpredict/models/genome/feature_meta.json +536 -0
- amrpredict/models/genome/genome_metrics.json +52 -0
- amrpredict/models/genome/model.txt +10704 -0
- amrpredict/models/genome_genes/feature_meta.json +811 -0
- amrpredict/models/genome_genes/genome_metrics.json +52 -0
- amrpredict/models/genome_genes/model.txt +10961 -0
- amrpredict/models/genome_genes/species_profiles.npz +0 -0
- amrpredict/models/genus_ab_rate_full.joblib +0 -0
- amrpredict/models/lgbm_meta.joblib +0 -0
- amrpredict/models/lgbm_metrics.json +97 -0
- amrpredict/models/rl_agent/policy.npz +0 -0
- amrpredict/models/rl_agent/rl_agent.json +22 -0
- amrpredict/models/taxon_ab_rate_full.joblib +0 -0
- amrpredict/models/taxon_species.csv +13059 -0
- amrpredict/models/timeline_calibration.json +21 -0
- amrpredict/models/timeline_ml/model.txt +1683 -0
- amrpredict/models/timeline_ml/timeline_metrics.json +145 -0
- amrpredict/models/timeline_ml/timeline_ml.json +1 -0
- amrpredict/registry.py +67 -0
- amrpredict/rl_agent.py +177 -0
- amrpredict/timeline.py +275 -0
- amrpredict/timeline_ml.py +174 -0
- amrpredict-0.3.0.dist-info/METADATA +304 -0
- amrpredict-0.3.0.dist-info/RECORD +35 -0
- amrpredict-0.3.0.dist-info/WHEEL +4 -0
- amrpredict-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|