ecbackoff 1.1__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.
ecbackoff/__init__.py ADDED
@@ -0,0 +1,701 @@
1
+ #!/usr/bin/env python3
2
+ """ecbackoff: inherit an enzyme's EC number only where the neighbourhood supports it; otherwise back off to three digits, or say nothing.
3
+
4
+ For every query protein the program reads the sequence-search hits against a bundled reference of experimentally
5
+ characterized enzymes (UniProtKB/Swiss-Prot 2026_04), computes 18 features of the neighbourhood of the nearest enzyme,
6
+ and estimates the probability that inheriting that enzyme's four-digit EC number, or its first three digits, would be
7
+ wrong. It then says
8
+
9
+ EC4 the four-digit EC number(s) of the nearest enzyme,
10
+ EC3 only the first three digits (the sub-subclass),
11
+ none nothing.
12
+
13
+ Commands
14
+ run FASTA in, table out (needs MMseqs2 on the PATH)
15
+ score MMseqs2 hit table + FASTA in, table out (no MMseqs2 needed)
16
+ reference write the reference sequences (FASTA) for running the search yourself
17
+ selftest checks the installation: file checksums, bundled example, edge cases
18
+ info model, reference and evidence summary
19
+
20
+ Standard library only (Python 3.8 or newer). See README.md.
21
+ """
22
+ import argparse
23
+ import bisect
24
+ import contextlib
25
+ import datetime
26
+ import gzip
27
+ import hashlib
28
+ import io
29
+ import json
30
+ import math
31
+ import os
32
+ import shlex
33
+ import shutil
34
+ import subprocess
35
+ import sys
36
+ import tempfile
37
+
38
+ VERSION = '1.1'
39
+ HERE = os.path.dirname(os.path.abspath(__file__))
40
+ DATA = os.path.join(HERE, 'data')
41
+ EXAMPLE = os.path.join(HERE, 'example')
42
+ SEARCH_ARGS = ['-s', '7.5', '-e', '1e-3', '--max-seqs', '20000', '--alignment-mode', '3']
43
+ FORMAT = 'query,target,fident,qcov,tcov,evalue,bits'
44
+ MMSEQS_TESTED = '18.8cc5c'
45
+ MIN_COV, TOPK = 0.7, 10
46
+ COLUMNS = ['query', 'nearest_enzyme', 'identity', 'nearest_ec', 'n_neighbours', 'p_wrong_4digit', 'p_wrong_3digit', 'statement', 'statement_ec', 'identity_rule', 'note']
47
+
48
+
49
+ def die(msg):
50
+ print(f'error: {msg}', file=sys.stderr)
51
+ sys.exit(2)
52
+
53
+
54
+ def warn(msg):
55
+ print(f'[ecbackoff] warning: {msg}', file=sys.stderr)
56
+
57
+
58
+ def lines(path):
59
+ """Lines of a text or .gz file without line ends. Tolerates a UTF-8 byte-order mark, Windows line ends and stray bytes."""
60
+ try:
61
+ fh = gzip.open(path, 'rt', encoding='utf-8-sig', errors='replace') if path.endswith('.gz') else open(path, 'r', encoding='utf-8-sig', errors='replace')
62
+ except OSError as e:
63
+ die(f'cannot read {path}: {e.strerror or e}')
64
+ try:
65
+ for line in fh:
66
+ yield line.rstrip('\r\n')
67
+ except (OSError, EOFError) as e:
68
+ die(f'cannot read {path}: {e}')
69
+ finally:
70
+ fh.close()
71
+
72
+
73
+ def sha256_of(path):
74
+ h = hashlib.sha256()
75
+ with open(path, 'rb') as fh:
76
+ for block in iter(lambda: fh.read(1 << 20), b''):
77
+ h.update(block)
78
+ return h.hexdigest()
79
+
80
+
81
+ def read_fasta(path):
82
+ """Ordered {identifier: length}; the identifier is the first word of the header, as MMseqs2 uses it."""
83
+ lengths, cur, odd = {}, None, 0
84
+ for n, line in enumerate(lines(path), 1):
85
+ line = line.strip()
86
+ if not line:
87
+ continue
88
+ if line.startswith('>'):
89
+ words = line[1:].split()
90
+ if not words:
91
+ die(f'{path}, line {n}: a header has no identifier')
92
+ cur = words[0]
93
+ if cur in lengths:
94
+ die(f'identifier {cur!r} occurs twice in {path}')
95
+ lengths[cur] = 0
96
+ else:
97
+ if cur is None:
98
+ die(f'{path} does not look like a FASTA file: line {n} is sequence before any ">" header')
99
+ seq = line.replace('*', '').replace('-', '')
100
+ lengths[cur] += len(seq)
101
+ if not seq.isalpha():
102
+ odd += 1
103
+ if not lengths:
104
+ die(f'{path} contains no sequences')
105
+ if odd:
106
+ warn(f'{odd} sequence line(s) in {path} contain characters other than letters, "-" and "*"; they are counted in the length')
107
+ return lengths
108
+
109
+
110
+ def write_clean_fasta(src, dst):
111
+ """Copy of the query FASTA for the search: one-word identifiers, letters only, empty sequences left out. Returns the identifiers left out."""
112
+ empty, cur, buf = [], None, []
113
+
114
+ def flush(fh):
115
+ if cur is None:
116
+ return
117
+ seq = ''.join(buf)
118
+ if seq:
119
+ fh.write(f'>{cur}\n{seq}\n')
120
+ else:
121
+ empty.append(cur)
122
+
123
+ with open(dst, 'w', newline='\n') as fh:
124
+ for line in lines(src):
125
+ line = line.strip()
126
+ if not line:
127
+ continue
128
+ if line.startswith('>'):
129
+ flush(fh)
130
+ cur, buf = line[1:].split()[0], []
131
+ else:
132
+ buf.append(line.replace('*', '').replace('-', '').replace(' ', '').upper())
133
+ flush(fh)
134
+ return empty
135
+
136
+
137
+ def pref3(ecs):
138
+ return {'.'.join(e.split('.')[:3]) for e in ecs}
139
+
140
+
141
+ class Reference:
142
+ """Experimentally characterized enzymes of the frozen reference: length, year of entry, EC numbers with the year each was established."""
143
+
144
+ def __init__(self, data_dir=DATA):
145
+ self.length, self.year, self.labels, self.by_ec = {}, {}, {}, {}
146
+ self._p3 = {}
147
+ path = os.path.join(data_dir, 'reference.tsv.gz')
148
+ if not os.path.exists(path):
149
+ die(f'reference data not found: {path} (is the data/ folder next to ecbackoff.py?)')
150
+ for line in lines(path):
151
+ acc, length, year, ecs = line.split('\t')
152
+ self.length[acc], self.year[acc] = int(length), int(year)
153
+ labs = set()
154
+ for item in ecs.split(';'):
155
+ e, y = item.rsplit(':', 1)
156
+ labs.add(e)
157
+ self.by_ec.setdefault(e, []).append(int(y))
158
+ self.labels[acc] = labs
159
+ for e in self.by_ec:
160
+ self.by_ec[e].sort()
161
+
162
+ def p3(self, acc):
163
+ if acc not in self._p3:
164
+ self._p3[acc] = pref3(self.labels[acc])
165
+ return self._p3[acc]
166
+
167
+
168
+ def load_model(data_dir=DATA):
169
+ path = os.path.join(data_dir, 'model.json')
170
+ if not os.path.exists(path):
171
+ die(f'model not found: {path}')
172
+ with open(path) as fh:
173
+ return json.load(fh)
174
+
175
+
176
+ def accession_of(identifier):
177
+ """UniProt-style 'sp|P12345|NAME' -> 'P12345'; anything else unchanged."""
178
+ parts = identifier.split('|')
179
+ return parts[1] if len(parts) >= 3 else identifier
180
+
181
+
182
+ def make_alias(qids):
183
+ """Identifier as it may appear in a hit table -> identifier in the FASTA. MMseqs2 prints the accession of a UniProt-style header
184
+ ('sp|P12345|NAME' -> 'P12345'); an accession shared by two queries stays unresolved."""
185
+ alias = {q: q for q in qids}
186
+ by_acc = {}
187
+ for q in qids:
188
+ a = accession_of(q)
189
+ if a != q:
190
+ by_acc.setdefault(a, []).append(q)
191
+ for a, qs in by_acc.items():
192
+ if a not in alias and len(qs) == 1:
193
+ alias[a] = qs[0]
194
+ return alias
195
+
196
+
197
+ def read_hits(path, ref, qids, keep_self):
198
+ """Hits that qualify as neighbours (both sequences covered to at least 70%), best bit score first. Stops with a clear message on a malformed table."""
199
+ alias = make_alias(qids)
200
+ per, unknown, n_hits, n_ref, n_badbits = {}, {}, 0, 0, 0
201
+ for n, line in enumerate(lines(path), 1):
202
+ if not line.strip():
203
+ continue
204
+ p = line.split('\t')
205
+ if len(p) < 7:
206
+ die(f'{path}, line {n}: expected 7 tab-separated columns ({FORMAT}), found {len(p)}')
207
+ try:
208
+ fident, qcov, tcov, bits = float(p[2]), float(p[3]), float(p[4]), float(p[6])
209
+ except ValueError:
210
+ if n == 1:
211
+ continue # a header line
212
+ die(f'{path}, line {n}: identity, coverages and bit score must be numbers ({FORMAT})')
213
+ if not all(math.isfinite(x) for x in (fident, qcov, tcov, bits)):
214
+ die(f'{path}, line {n}: a value is not finite')
215
+ if min(fident, qcov, tcov) < 0 or max(fident, qcov, tcov) > 1.0001:
216
+ die(f'{path}, line {n}: identity and coverages must be fractions between 0 and 1; was the table made with --format-output {FORMAT}?')
217
+ n_hits += 1
218
+ q0, t = p[0], p[1]
219
+ q = alias.get(q0)
220
+ if q is None:
221
+ unknown[q0] = unknown.get(q0, 0) + 1
222
+ continue
223
+ if t not in ref.labels:
224
+ continue
225
+ n_ref += 1
226
+ if not keep_self and (t == q or t == accession_of(q)):
227
+ continue
228
+ if bits <= 0:
229
+ n_badbits += 1
230
+ continue
231
+ if qcov >= MIN_COV and tcov >= MIN_COV:
232
+ per.setdefault(q, []).append((t, fident, qcov, tcov, bits))
233
+ if unknown:
234
+ ex = sorted(unknown)[:3]
235
+ die(f'the hit table has {len(unknown)} query identifier(s) that are not in the FASTA, e.g. {ex}; the identifier is the first word of the FASTA header '
236
+ '(an accession shared by several UniProt-style identifiers cannot be resolved)')
237
+ if n_hits and not n_ref:
238
+ die('none of the hits points to a reference enzyme; the search must be run against the bundled reference (ecbackoff reference -o reference.fasta)')
239
+ if n_badbits:
240
+ warn(f'{n_badbits} hit(s) with a bit score of 0 or less were ignored')
241
+ for q in per:
242
+ per[q].sort(key=lambda x: -x[4]) # stable: ties keep file order
243
+ return per
244
+
245
+
246
+ def features(g, qlen, ref, py):
247
+ t = [x[0] for x in g]
248
+ f = [x[1] for x in g]
249
+ b = [x[4] for x in g]
250
+ nn = t[0]
251
+ ln = ref.labels[nn]
252
+ top = t[:TOPK]
253
+ others = top[1:]
254
+ nn3 = ref.p3(nn)
255
+ return {
256
+ 'ident': f[0], 'log_bits': math.log(b[0]), 'qcov': g[0][2], 'tcov': g[0][3],
257
+ 'margin': (b[0] - b[1]) / b[0] if len(b) > 1 else 1.0,
258
+ 'log_nnb': math.log1p(len(t)),
259
+ 'k_agree': (sum(1 for x in others if ref.labels[x] & ln) / len(others)) if others else 1.0,
260
+ 'k_single': 1 if not others else 0,
261
+ 'log_k_distinct': math.log1p(len(set().union(*[ref.labels[x] for x in top]))),
262
+ 'nn_nlabels': len(ln),
263
+ 'log_qlen': math.log(qlen), 'abs_log_lenratio': abs(math.log(qlen / ref.length[nn])),
264
+ 'log_label_freq': math.log1p(max(bisect.bisect_right(ref.by_ec[e], py - 1) for e in ln)),
265
+ 'l3_agree_k': (sum(1 for x in others if ref.p3(x) & nn3) / len(others)) if others else 1.0,
266
+ 'log_k_l3_distinct': math.log1p(len(set().union(*[ref.p3(x) for x in top]))),
267
+ 'log_fam_n_ec': math.log1p(len(set().union(*[ref.labels[x] for x in t]))),
268
+ 'ident_other': next((f[i] for i, x in enumerate(t) if not (ref.labels[x] & ln)), 0.0),
269
+ 'nn_age': float(py - ref.year[nn]),
270
+ }
271
+
272
+
273
+ def p_wrong(model, key, feats):
274
+ s, m = model['scaler'], model[key]
275
+ z = m['intercept'] + sum(((feats[n] - mu) / sc) * c for n, mu, sc, c in zip(model['features'], s['mean'], s['scale'], m['coef']))
276
+ if not math.isfinite(z):
277
+ return None
278
+ z = max(-60.0, min(60.0, z))
279
+ return 1.0 / (1.0 + math.exp(-z))
280
+
281
+
282
+ def fmt(x, nd=4):
283
+ return '' if x is None else f'{x:.{nd}f}'
284
+
285
+
286
+ def analyse(qlens, per, ref, model, py):
287
+ """One record per query, in the order of the FASTA. The three-digit risk is capped at the four-digit risk: a three-digit error is also a four-digit error."""
288
+ rows = []
289
+ for q, qlen in qlens.items():
290
+ r = {'query': q, 'nearest_enzyme': '', 'identity': None, 'nearest_ec': '', 'n_neighbours': 0, 'p4': None, 'p3': None, 'statement': 'none',
291
+ 'statement_ec': '', 'identity_rule': 'none', 'note': ''}
292
+ g = per.get(q)
293
+ if qlen <= 0:
294
+ r['note'] = 'empty_sequence'
295
+ elif not g:
296
+ r['note'] = 'no_neighbour_covering_70pct_of_both_sequences'
297
+ else:
298
+ ft = features(g, qlen, ref, py)
299
+ p4, p3 = p_wrong(model, 'wrong_4digit', ft), p_wrong(model, 'wrong_3digit', ft)
300
+ r.update({'nearest_enzyme': g[0][0], 'identity': ft['ident'], 'nearest_ec': ';'.join(sorted(ref.labels[g[0][0]])), 'n_neighbours': len(g)})
301
+ if p4 is None or p3 is None:
302
+ r['note'] = 'score_not_finite'
303
+ else:
304
+ r.update({'p4': p4, 'p3': min(p3, p4)})
305
+ rows.append(r)
306
+ return rows
307
+
308
+
309
+ def decide_match(rows, hi, lo):
310
+ """As many four-digit and three-digit statements as the identity rule gives on this batch; the model chooses which proteins get them."""
311
+ ok = [r for r in rows if r['p4'] is not None]
312
+ n4 = sum(1 for r in ok if r['identity'] >= hi)
313
+ n3 = sum(1 for r in ok if lo <= r['identity'] < hi)
314
+ first = sorted(ok, key=lambda r: (r['p4'], r['query']))[:n4]
315
+ taken = {r['query'] for r in first}
316
+ second = sorted([r for r in ok if r['query'] not in taken], key=lambda r: (r['p3'], r['query']))[:n3]
317
+ for r in first:
318
+ r['statement'] = 'EC4'
319
+ for r in second:
320
+ r['statement'] = 'EC3'
321
+
322
+
323
+ def decide_cutoff(rows, c4, c3):
324
+ for r in rows:
325
+ if r['p4'] is None:
326
+ continue
327
+ if r['p4'] <= c4:
328
+ r['statement'] = 'EC4'
329
+ elif r['p3'] <= c3:
330
+ r['statement'] = 'EC3'
331
+
332
+
333
+ def finish(rows, ref, hi, lo):
334
+ for r in rows:
335
+ if r['identity'] is not None:
336
+ r['identity_rule'] = 'EC4' if r['identity'] >= hi else ('EC3' if r['identity'] >= lo else 'none')
337
+ labs = ref.labels[r['nearest_enzyme']]
338
+ if r['statement'] == 'EC4':
339
+ r['statement_ec'] = ';'.join(sorted(labs))
340
+ elif r['statement'] == 'EC3':
341
+ r['statement_ec'] = ';'.join(sorted(pref3(labs)))
342
+ if r['statement'] == 'none' and not r['note']:
343
+ r['note'] = 'neighbourhood does not support a statement'
344
+
345
+
346
+ def classify(qlens, per, ref, model, py, mode, hi, lo, c4, c3):
347
+ rows = analyse(qlens, per, ref, model, py)
348
+ if mode == 'match':
349
+ decide_match(rows, hi, lo)
350
+ else:
351
+ decide_cutoff(rows, c4, c3)
352
+ finish(rows, ref, hi, lo)
353
+ return rows
354
+
355
+
356
+ def write_table(rows, out):
357
+ if out and out != '-':
358
+ try:
359
+ fh = open(out, 'w', encoding='utf-8', newline='\n')
360
+ except OSError as e:
361
+ die(f'cannot write {out}: {e.strerror or e}')
362
+ else:
363
+ fh = sys.stdout
364
+ try:
365
+ fh.write('\t'.join(COLUMNS) + '\n')
366
+ for r in rows:
367
+ fh.write('\t'.join([r['query'], r['nearest_enzyme'], fmt(r['identity'], 3), r['nearest_ec'], str(r['n_neighbours']), fmt(r['p4']), fmt(r['p3']),
368
+ r['statement'], r['statement_ec'], r['identity_rule'], r['note']]) + '\n')
369
+ finally:
370
+ if fh is not sys.stdout:
371
+ fh.close()
372
+
373
+
374
+ def counts(rows):
375
+ return {'queries': len(rows), 'with_neighbour': sum(1 for r in rows if r['p4'] is not None),
376
+ 'statements': {k: sum(1 for r in rows if r['statement'] == k) for k in ('EC4', 'EC3', 'none')},
377
+ 'identity_rule': {k: sum(1 for r in rows if r['identity_rule'] == k) for k in ('EC4', 'EC3', 'none')}}
378
+
379
+
380
+ def summary(c, mode):
381
+ print(f'[ecbackoff] {c["queries"]} queries, {c["with_neighbour"]} with a qualifying neighbour ({mode} mode)', file=sys.stderr)
382
+ s, i = c['statements'], c['identity_rule']
383
+ print(f'[ecbackoff] statements: four digits {s["EC4"]}, three digits {s["EC3"]}, none {s["none"]}', file=sys.stderr)
384
+ print(f'[ecbackoff] identity rule (for comparison): four digits {i["EC4"]}, three digits {i["EC3"]}, none {i["none"]}', file=sys.stderr)
385
+ if mode == 'match' and c['with_neighbour'] < 200:
386
+ warn('fewer than 200 queries with a neighbour; the matched numbers are noisy, consider --mode cutoff')
387
+
388
+
389
+ def check_options(args):
390
+ if not (0 <= args.identity_lo <= args.identity_hi <= 1):
391
+ die('--identity-lo and --identity-hi must satisfy 0 <= lo <= hi <= 1')
392
+ for name in ('cutoff4', 'cutoff3'):
393
+ if not (0 <= getattr(args, name) <= 1):
394
+ die(f'--{name} must be a probability between 0 and 1')
395
+
396
+
397
+ def write_params(args, c, extra):
398
+ """Record next to the output table how it was made."""
399
+ if args.out in ('', '-') or args.no_params:
400
+ return
401
+ p = {'ecbackoff_version': VERSION, 'date_utc': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'), 'command': shlex.join(sys.argv),
402
+ 'mode': args.mode, 'cutoff4': args.cutoff4, 'cutoff3': args.cutoff3, 'identity_hi': args.identity_hi, 'identity_lo': args.identity_lo,
403
+ 'prediction_year': args.py, 'prediction_year_is_frozen': args.py == args.model['prediction_year'],
404
+ 'reference': args.model['reference_release'], 'query_fasta': os.path.abspath(args.query), 'query_fasta_sha256': sha256_of(args.query), 'counts': c}
405
+ p.update(extra)
406
+ try:
407
+ with open(args.out + '.params.json', 'w') as fh:
408
+ json.dump(p, fh, indent=1)
409
+ except OSError as e:
410
+ warn(f'could not write the parameter file: {e.strerror or e}')
411
+
412
+
413
+ def process(args, hits_path, qlens=None):
414
+ check_options(args)
415
+ ref, model = Reference(), load_model()
416
+ qlens = qlens if qlens is not None else read_fasta(args.query)
417
+ per = read_hits(hits_path, ref, list(qlens), args.keep_self)
418
+ py = args.year if args.year else model['prediction_year']
419
+ if py != model['prediction_year']:
420
+ warn(f'--year {py} differs from the frozen prediction year {model["prediction_year"]}; it changes two features (years since the nearest enzyme was established, '
421
+ 'and the number of earlier proteins carrying its EC number) and is outside what was validated')
422
+ args.py, args.model = py, model
423
+ rows = classify(qlens, per, ref, model, py, args.mode, args.identity_hi, args.identity_lo, args.cutoff4, args.cutoff3)
424
+ return rows
425
+
426
+
427
+ def finish_command(args, rows, extra):
428
+ c = counts(rows)
429
+ summary(c, args.mode)
430
+ write_table(rows, args.out)
431
+ write_params(args, c, extra)
432
+
433
+
434
+ def cmd_score(args):
435
+ rows = process(args, args.hits)
436
+ finish_command(args, rows, {'hit_table': os.path.abspath(args.hits), 'hit_table_sha256': sha256_of(args.hits)})
437
+
438
+
439
+ def find_mmseqs(arg):
440
+ mm = shutil.which(arg) if arg else shutil.which('mmseqs')
441
+ if not mm:
442
+ if arg:
443
+ die(f'--mmseqs {arg!r} is not an executable file')
444
+ die('MMseqs2 was not found on the PATH; install it (https://github.com/soedinglab/MMseqs2) or give --mmseqs, '
445
+ 'or run the search yourself ("ecbackoff reference" writes the reference) and use the "score" command')
446
+ try:
447
+ v = subprocess.run([mm, 'version'], capture_output=True, text=True, timeout=120)
448
+ except (OSError, subprocess.SubprocessError) as e:
449
+ die(f'{mm} could not be run: {e}')
450
+ if v.returncode != 0:
451
+ die(f'{mm} version failed; is it an MMseqs2 executable?')
452
+ version = v.stdout.strip().splitlines()[0] if v.stdout.strip() else 'unknown'
453
+ # the conda build prints "18.8cc5c", the official Windows build prints the full commit hash, which starts with 8cc5c
454
+ if version != MMSEQS_TESTED and not version.startswith(MMSEQS_TESTED.split('.')[-1]):
455
+ warn(f'MMseqs2 {version} found; the model was built and checked with {MMSEQS_TESTED}')
456
+ return mm, version
457
+
458
+
459
+ def cmd_run(args):
460
+ check_options(args)
461
+ qlens = read_fasta(args.query) # validate before searching
462
+ mm, mm_version = find_mmseqs(args.mmseqs)
463
+ if args.tmpdir and not os.path.isdir(args.tmpdir):
464
+ die(f'--tmpdir {args.tmpdir!r} is not a directory')
465
+ with tempfile.TemporaryDirectory(prefix='ecbackoff_', dir=args.tmpdir or None) as tmp:
466
+ refq, clean = os.path.join(tmp, 'reference.fasta'), os.path.join(tmp, 'queries.fasta')
467
+ with gzip.open(os.path.join(DATA, 'reference.fasta.gz'), 'rb') as src, open(refq, 'wb') as dst:
468
+ shutil.copyfileobj(src, dst)
469
+ empty = write_clean_fasta(args.query, clean)
470
+ if empty:
471
+ warn(f'{len(empty)} query sequence(s) are empty and are not searched (e.g. {empty[:3]})')
472
+ if len(empty) == len(qlens):
473
+ hits = os.path.join(tmp, 'hits.m8')
474
+ open(hits, 'w').close()
475
+ else:
476
+ hits = args.keep_hits or os.path.join(tmp, 'hits.m8')
477
+ cmd = [mm, 'easy-search', clean, refq, hits, os.path.join(tmp, 'tmp')] + SEARCH_ARGS + ['--threads', str(args.threads), '-v', '1', '--format-output', FORMAT]
478
+ print('[ecbackoff] ' + shlex.join(cmd), file=sys.stderr)
479
+ r = subprocess.run(cmd)
480
+ if r.returncode != 0:
481
+ die(f'MMseqs2 failed with exit code {r.returncode}; the command is printed above')
482
+ if not os.path.exists(hits):
483
+ die('MMseqs2 finished without writing a hit table')
484
+ rows = process(args, hits, qlens)
485
+ finish_command(args, rows, {'mmseqs': mm_version, 'search': ' '.join(SEARCH_ARGS), 'threads': args.threads})
486
+
487
+
488
+ def cmd_reference(args):
489
+ src_path = os.path.join(DATA, 'reference.fasta.gz')
490
+ if not os.path.exists(src_path):
491
+ die(f'reference data not found: {src_path}')
492
+ with gzip.open(src_path, 'rb') as src:
493
+ if args.out == '-':
494
+ shutil.copyfileobj(src, sys.stdout.buffer)
495
+ else:
496
+ try:
497
+ with open(args.out, 'wb') as dst:
498
+ shutil.copyfileobj(src, dst)
499
+ except OSError as e:
500
+ die(f'cannot write {args.out}: {e.strerror or e}')
501
+ print('[ecbackoff] search with:', file=sys.stderr)
502
+ print(f' mmseqs easy-search QUERIES.fasta {"REFERENCE.fasta" if args.out == "-" else args.out} hits.m8 tmp {" ".join(SEARCH_ARGS)} --format-output {FORMAT}', file=sys.stderr)
503
+ print('then: ecbackoff score -q QUERIES.fasta --hits hits.m8 -o result.tsv', file=sys.stderr)
504
+
505
+
506
+ # ---------------------------------------------------------------- selftest
507
+
508
+ def read_checksums():
509
+ path = os.path.join(HERE, 'data', 'CHECKSUMS.sha256')
510
+ if not os.path.exists(path):
511
+ return None
512
+ out = {}
513
+ for line in lines(path):
514
+ if line.strip():
515
+ digest, name = line.split(None, 1)
516
+ out[name.strip()] = digest
517
+ return out
518
+
519
+
520
+ def cmd_selftest(args):
521
+ results = []
522
+
523
+ def check(name, ok, detail=''):
524
+ results.append(bool(ok))
525
+ print(f'[selftest] {"ok " if ok else "FAIL"} {name}' + (f' ({detail})' if detail else ''))
526
+
527
+ # 1. files
528
+ sums = read_checksums()
529
+ if sums is None:
530
+ check('data/CHECKSUMS.sha256 present', False, 'the checksum file is missing')
531
+ else:
532
+ bad = [n for n, d in sums.items() if not os.path.exists(os.path.join(HERE, n)) or sha256_of(os.path.join(HERE, n)) != d]
533
+ check('file checksums', not bad and len(sums) >= 6, f'{len(sums)} files' if not bad else f'changed or missing: {bad}')
534
+ ref, model = Reference(), load_model()
535
+ n_fa, ids = 0, set()
536
+ for line in lines(os.path.join(DATA, 'reference.fasta.gz')):
537
+ if line.startswith('>'):
538
+ n_fa += 1
539
+ ids.add(line[1:].split()[0])
540
+ check('reference sequences match the reference table', ids == set(ref.labels) and n_fa == len(ids), f'{n_fa} sequences, {len(ref.labels)} table rows')
541
+
542
+ # 2. the bundled example against the released scores
543
+ q, h, e = (os.path.join(EXAMPLE, n) for n in ('queries.fasta', 'hits.m8', 'expected.tsv'))
544
+ qlens = read_fasta(q)
545
+ per = read_hits(h, ref, list(qlens), False)
546
+ rows = classify(qlens, per, ref, model, model['prediction_year'], 'cutoff', 0.6, 0.4, 0.10, 0.10)
547
+ got = {r['query']: r for r in rows}
548
+ exp = {}
549
+ for i, line in enumerate(lines(e)):
550
+ if i == 0:
551
+ head = line.split('\t')
552
+ elif line.strip():
553
+ d = dict(zip(head, line.split('\t')))
554
+ exp[d['query']] = d
555
+ worst4 = worst3 = 0.0
556
+ bad = 0
557
+ for k, d in exp.items():
558
+ r = got.get(k)
559
+ if r is None or r['p4'] is None or r['nearest_enzyme'] != d['nearest_enzyme']:
560
+ bad += 1
561
+ continue
562
+ worst4 = max(worst4, abs(r['p4'] - float(d['p_wrong_4digit'])))
563
+ worst3 = max(worst3, abs(r['p3'] - float(d['p_wrong_3digit'])))
564
+ check('example reproduces the released scores', bad == 0 and worst4 < 1e-6 and worst3 < 1e-6 and exp,
565
+ f'{len(exp)} proteins, wrong or missing {bad}, max difference {worst4:.1e} and {worst3:.1e}')
566
+ scored = [r for r in rows if r['p4'] is not None]
567
+ check('three-digit risk never above four-digit risk', all(r['p3'] <= r['p4'] for r in scored))
568
+
569
+ # 3. matched mode gives as many statements as the identity rule
570
+ rm = classify(qlens, per, ref, model, model['prediction_year'], 'match', 0.6, 0.4, 0.10, 0.10)
571
+ n4 = sum(1 for r in rm if r['p4'] is not None and r['identity'] >= 0.6)
572
+ n3 = sum(1 for r in rm if r['p4'] is not None and 0.4 <= r['identity'] < 0.6)
573
+ check('match mode keeps the numbers of the identity rule', sum(r['statement'] == 'EC4' for r in rm) == n4 and sum(r['statement'] == 'EC3' for r in rm) == n3, f'{n4} and {n3}')
574
+
575
+ # 4. edge cases built from the example
576
+ hit_lines = [x for x in lines(h) if x.strip()]
577
+ first = next(iter(exp)) # a query with a neighbour
578
+ orig = got[first]
579
+ with tempfile.TemporaryDirectory(prefix='ecbackoff_selftest_') as tmp:
580
+ # a query without any hit
581
+ ql = dict(qlens)
582
+ ql['no_hit_query'] = 120
583
+ pr = read_hits(h, ref, list(ql), False)
584
+ r0 = classify(ql, pr, ref, model, model['prediction_year'], 'cutoff', 0.6, 0.4, 0.10, 0.10)[-1]
585
+ check('query without a neighbour gets none and a note', r0['statement'] == 'none' and r0['p4'] is None and r0['note'] != '')
586
+ # an empty sequence
587
+ ql2 = {'empty_query': 0}
588
+ r1 = classify(ql2, {}, ref, model, model['prediction_year'], 'cutoff', 0.6, 0.4, 0.10, 0.10)[0]
589
+ check('empty sequence gets none and a note', r1['statement'] == 'none' and r1['note'] == 'empty_sequence')
590
+ # accession in the hit table, UniProt-style identifier in the FASTA
591
+ qa = {f'sp|{first}|TEST_HUMAN': qlens[first]}
592
+ hp = os.path.join(tmp, 'alias.m8')
593
+ with open(hp, 'w') as fh:
594
+ fh.write('\n'.join(x for x in hit_lines if x.split('\t')[0] == first) + '\n')
595
+ ra = classify(qa, read_hits(hp, ref, list(qa), False), ref, model, model['prediction_year'], 'cutoff', 0.6, 0.4, 0.10, 0.10)[0]
596
+ check('sp|ACCESSION|NAME identifier is matched to the accession in the hit table', ra['p4'] is not None and abs(ra['p4'] - orig['p4']) < 1e-12)
597
+ # a hit to the query itself (named after a reference accession that is not among its neighbours) is dropped unless --keep-self
598
+ targets = {x.split('\t')[1] for x in hit_lines if x.split('\t')[0] == first}
599
+ acc = next(a for a in ref.labels if a not in targets)
600
+ qs = {f'sp|{acc}|SELF_HUMAN': qlens[first]}
601
+ hp2 = os.path.join(tmp, 'self.m8')
602
+ with open(hp2, 'w') as fh:
603
+ fh.write(f'{acc}\t{acc}\t1.000\t1.000\t1.000\t0\t5000\n')
604
+ fh.write('\n'.join(x.replace(first, acc, 1) for x in hit_lines if x.split('\t')[0] == first) + '\n')
605
+ r_drop = classify(qs, read_hits(hp2, ref, list(qs), False), ref, model, model['prediction_year'], 'cutoff', 0.6, 0.4, 0.10, 0.10)[0]
606
+ r_keep = classify(qs, read_hits(hp2, ref, list(qs), True), ref, model, model['prediction_year'], 'cutoff', 0.6, 0.4, 0.10, 0.10)[0]
607
+ check('self hit is dropped', r_drop['p4'] is not None and abs(r_drop['p4'] - orig['p4']) < 1e-12 and r_drop['nearest_enzyme'] == orig['nearest_enzyme'])
608
+ check('self hit is kept with --keep-self', r_keep['nearest_enzyme'] == acc and r_keep['identity'] == 1.0)
609
+ # malformed input stops with a message
610
+ bad_hits = os.path.join(tmp, 'bad.m8')
611
+ with open(bad_hits, 'w') as fh:
612
+ fh.write(f'{first}\t{orig["nearest_enzyme"]}\t88.0\t1.0\t1.0\t0\t100\n')
613
+ try:
614
+ with contextlib.redirect_stderr(io.StringIO()): # the refusal message is expected here
615
+ read_hits(bad_hits, ref, list(qlens), False)
616
+ check('percent identity in the hit table is refused', False)
617
+ except SystemExit:
618
+ check('percent identity in the hit table is refused', True)
619
+
620
+ ok = all(results)
621
+ print('[selftest] ' + ('PASS' if ok else 'FAIL'))
622
+ sys.exit(0 if ok else 1)
623
+
624
+
625
+ def cmd_info(args):
626
+ m = load_model()
627
+ ref = Reference()
628
+ print(f'ecbackoff {VERSION}')
629
+ print(f'reference: {m["reference_release"]}, {len(ref.labels)} experimentally characterized enzymes, {len(ref.by_ec)} EC numbers')
630
+ print(f'prediction year of the frozen model: {m["prediction_year"]}')
631
+ print(f'search: {m["search"]["program"]} {" ".join(SEARCH_ARGS)}; neighbours need coverage >= {MIN_COV} of both sequences')
632
+ print(f'model: logistic regression on {len(m["features"])} standardized features, trained on {m["wrong_4digit"]["training_rows"]} proteins (years {m["training_years"][0]}-{m["training_years"][1]})')
633
+ print('evidence (retrospective; see README):')
634
+ for line in m['evidence']:
635
+ print(' ' + line)
636
+
637
+
638
+ def build_parser():
639
+ ap = argparse.ArgumentParser(prog='ecbackoff', description=__doc__.split('\n\n')[0], formatter_class=argparse.RawDescriptionHelpFormatter)
640
+ ap.add_argument('--version', action='version', version=f'ecbackoff {VERSION}')
641
+ sub = ap.add_subparsers(dest='cmd', required=True)
642
+
643
+ def common(p):
644
+ p.add_argument('-q', '--query', required=True, help='FASTA of the query proteins (identifier = first word of the header)')
645
+ p.add_argument('-o', '--out', default='-', help='output table (TSV); default standard output. A file <out>.params.json records how the table was made')
646
+ p.add_argument('--no-params', action='store_true', help='do not write the .params.json file')
647
+ p.add_argument('--mode', choices=['match', 'cutoff'], default='cutoff',
648
+ help='cutoff: four digits if P(wrong) <= --cutoff4, else three digits if P(wrong) <= --cutoff3, else nothing; works for any batch size (default). '
649
+ 'match: as many four-digit and three-digit statements as the identity rule gives on this batch, chosen by model risk (the comparison made in the paper); '
650
+ 'meaningful only for batches of several hundred proteins')
651
+ p.add_argument('--identity-hi', type=float, default=0.6, help='identity rule: four digits at or above this (default 0.6)')
652
+ p.add_argument('--identity-lo', type=float, default=0.4, help='identity rule: three digits at or above this (default 0.4)')
653
+ p.add_argument('--cutoff4', type=float, default=0.10, help='cutoff mode: largest P(four-digit wrong) for a four-digit statement (default 0.10)')
654
+ p.add_argument('--cutoff3', type=float, default=0.10, help='cutoff mode: largest P(three-digit wrong) for a three-digit statement (default 0.10)')
655
+ p.add_argument('--year', type=int, default=0, help='prediction year (default: the frozen 2027). It enters two features, years since the nearest enzyme was '
656
+ 'established and the number of earlier proteins carrying its EC number; other values are outside what was validated')
657
+ p.add_argument('--keep-self', action='store_true', help='do not drop a hit that is the query itself (by identifier or UniProt accession)')
658
+
659
+ r = sub.add_parser('run', help='FASTA in, table out (needs MMseqs2)')
660
+ common(r)
661
+ r.add_argument('--threads', type=int, default=4)
662
+ r.add_argument('--mmseqs', default='', help='path to the mmseqs executable (default: from PATH)')
663
+ r.add_argument('--keep-hits', default='', help='write the MMseqs2 hit table here instead of a temporary file')
664
+ r.add_argument('--tmpdir', default='', help='directory for the temporary files (decompressed reference, MMseqs2 work files); default: the system temporary directory')
665
+ r.set_defaults(func=cmd_run)
666
+
667
+ s = sub.add_parser('score', help='MMseqs2 hit table in, table out')
668
+ common(s)
669
+ s.add_argument('--hits', required=True, help='hit table from: mmseqs easy-search QUERY reference.fasta HITS tmp -s 7.5 -e 1e-3 --max-seqs 20000 --alignment-mode 3 '
670
+ f'--format-output {FORMAT} (reference.fasta: "ecbackoff reference -o reference.fasta")')
671
+ s.set_defaults(func=cmd_score)
672
+
673
+ f = sub.add_parser('reference', help='write the reference sequences for running the search yourself')
674
+ f.add_argument('-o', '--out', default='reference.fasta', help='output FASTA (default reference.fasta; - for standard output)')
675
+ f.set_defaults(func=cmd_reference)
676
+
677
+ t = sub.add_parser('selftest', help='check the installation')
678
+ t.set_defaults(func=cmd_selftest)
679
+ i = sub.add_parser('info', help='model, reference and evidence summary')
680
+ i.set_defaults(func=cmd_info)
681
+ return ap
682
+
683
+
684
+ def main(argv=None):
685
+ if sys.version_info < (3, 8):
686
+ die('Python 3.8 or newer is needed')
687
+ args = build_parser().parse_args(argv)
688
+ try:
689
+ args.func(args)
690
+ except BrokenPipeError:
691
+ try:
692
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
693
+ except OSError:
694
+ pass
695
+ sys.exit(0)
696
+ except KeyboardInterrupt:
697
+ sys.exit(130)
698
+
699
+
700
+ if __name__ == '__main__':
701
+ main()