evolutionary-stability-optimizer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eso/__init__.py +11 -0
- eso/cli.py +111 -0
- eso/codon_usage.py +96 -0
- eso/constraints.py +179 -0
- eso/custom_score.py +170 -0
- eso/data/__init__.py +0 -0
- eso/data/human-antibody-heavy-chain-codon-frequencies.csv +62 -0
- eso/data/human-antibody-light-chain-codon-frequencies.csv +62 -0
- eso/detection/__init__.py +0 -0
- eso/detection/_overlap.py +112 -0
- eso/detection/common_motifs.py +81 -0
- eso/detection/dispatch.py +185 -0
- eso/detection/methylation.py +96 -0
- eso/detection/motif_utils.py +77 -0
- eso/detection/recombination.py +329 -0
- eso/detection/slippage.py +249 -0
- eso/detection/staubility_variant.py +284 -0
- eso/io_utils.py +312 -0
- eso/optimize.py +266 -0
- eso/pipeline.py +322 -0
- eso/report.py +57 -0
- eso/sequence_utils.py +41 -0
- evolutionary_stability_optimizer-0.1.0.dist-info/METADATA +501 -0
- evolutionary_stability_optimizer-0.1.0.dist-info/RECORD +27 -0
- evolutionary_stability_optimizer-0.1.0.dist-info/WHEEL +4 -0
- evolutionary_stability_optimizer-0.1.0.dist-info/entry_points.txt +3 -0
- evolutionary_stability_optimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
eso/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""ESO - Evolutionary Stability Optimizer.
|
|
2
|
+
|
|
3
|
+
Detects hypermutable sites (recombination, replication-slippage, and
|
|
4
|
+
methylation-motif hotspots) in engineered DNA sequences and optimizes them
|
|
5
|
+
away using DNAChisel, while preserving the amino-acid translation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from eso.pipeline import main, suspect_site_extractor
|
|
9
|
+
from eso.optimize import optimization_engine
|
|
10
|
+
|
|
11
|
+
__all__ = ["main", "suspect_site_extractor", "optimization_engine"]
|
eso/cli.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Command-line entry point: `eso-optimize --input-folder ... --output-path ...`."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from eso.custom_score import CustomScoreFileError, load_custom_score_from_file
|
|
9
|
+
from eso.io_utils import IndexesFileError, load_indexes_from_file
|
|
10
|
+
from eso.pipeline import main as run_pipeline
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_parser():
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
description="Detect and remove hypermutable sites (recombination, slippage, "
|
|
16
|
+
"methylation hotspots) from DNA sequences while preserving translation.")
|
|
17
|
+
parser.add_argument('--input-folder', default='.', help="Directory containing FASTA/GenBank files.")
|
|
18
|
+
parser.add_argument('--output-path', default=None, help="Directory to write results into (default: <input-folder>/output).")
|
|
19
|
+
parser.add_argument('--compute-motifs', action='store_true', help="Also detect methylation motif sites.")
|
|
20
|
+
parser.add_argument('--motifs-path', default=None, help="Path to a MEME-minimal-format PSSM file (optional - can be used with or instead of --common-motifs).")
|
|
21
|
+
parser.add_argument('--common-motifs', default=None,
|
|
22
|
+
help="Comma-separated common motif names to include, no file needed - see "
|
|
23
|
+
"eso.detection.common_motifs.COMMON_MOTIFS for the full list (E. coli methylation: "
|
|
24
|
+
"dam, dcm; cryptic ribosome binding: shine_dalgarno; cryptic sigma70 promoter "
|
|
25
|
+
"elements: sigma70_minus35, sigma70_minus10), e.g. --common-motifs dam,dcm. "
|
|
26
|
+
"At least one of --motifs-path/--common-motifs is required with --compute-motifs.")
|
|
27
|
+
parser.add_argument('--num-sites', type=float, default=np.inf,
|
|
28
|
+
help="Max hotspots to report per category (default: all). Only limits what's "
|
|
29
|
+
"written to the CSVs - every detected hotspot always gets a correction "
|
|
30
|
+
"constraint during optimization, regardless of this value.")
|
|
31
|
+
parser.add_argument('--no-optimize', action='store_true', help="Only detect hotspots, skip sequence optimization.")
|
|
32
|
+
parser.add_argument('--mini-gc', type=float, default=0.3)
|
|
33
|
+
parser.add_argument('--maxi-gc', type=float, default=0.7)
|
|
34
|
+
parser.add_argument('--method', default='use_best_codon',
|
|
35
|
+
choices=['use_best_codon', 'match_codon_usage', 'harmonize_rca'])
|
|
36
|
+
parser.add_argument('--organism-name', default='not_specified',
|
|
37
|
+
help="Host organism for codon optimization (species name, TaxID, or a custom table name "
|
|
38
|
+
"e.g. kompas/human_antibody_heavy_chain/human_antibody_light_chain). Ignored if "
|
|
39
|
+
"--custom-score-file is given.")
|
|
40
|
+
parser.add_argument('--custom-score-file', default=None,
|
|
41
|
+
help="Path to a Python file scoring sequences your own way, instead of CAI/tAI - see "
|
|
42
|
+
"examples/custom_score_template.py for a copyable starting point. Overrides "
|
|
43
|
+
"--organism-name and --method.")
|
|
44
|
+
parser.add_argument('--custom-score-function', default='score',
|
|
45
|
+
help="Name of the scoring function inside --custom-score-file (default: 'score'). "
|
|
46
|
+
"Only needed if you renamed it away from the template's default.")
|
|
47
|
+
parser.add_argument('--custom-score-minimize', action='store_true',
|
|
48
|
+
help="Treat a LOWER value from your custom score function as better (default: higher is better).")
|
|
49
|
+
parser.add_argument('--recombination-mode', default='thorough', choices=['thorough', 'fast'],
|
|
50
|
+
help="'thorough' (default): Levenshtein-tolerant, catches near-duplicate hotspots, "
|
|
51
|
+
"confirmed practical up to 1,000,000nt (see docs/detector-comparisons.md). "
|
|
52
|
+
"'fast': exact-match only, 19-34x faster but misses near-duplicates - "
|
|
53
|
+
"use only if that speed gap actually matters for your workload.")
|
|
54
|
+
parser.add_argument('--slippage-mode', default='default', choices=['default', 'fast'],
|
|
55
|
+
help="Both detect identical hotspots; 'default' is also faster at every length "
|
|
56
|
+
"tested (see eso.detection.dispatch) - 'fast' is kept as an independent "
|
|
57
|
+
"cross-check implementation, not for its speed.")
|
|
58
|
+
parser.add_argument('--indexes-file', default=None,
|
|
59
|
+
help="Path to a JSON file specifying ORF/exclusion regions per sequence - see "
|
|
60
|
+
"examples/indexes_template.json for a copyable starting point (region "
|
|
61
|
+
"strings are 1-indexed and inclusive, e.g. \"1-6, 51-68\"). Omit to treat "
|
|
62
|
+
"entire sequences as the ORF with no exclusions.")
|
|
63
|
+
return parser
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main(argv=None):
|
|
67
|
+
args = build_parser().parse_args(argv)
|
|
68
|
+
|
|
69
|
+
common_motifs = [name.strip() for name in args.common_motifs.split(',')] if args.common_motifs else None
|
|
70
|
+
|
|
71
|
+
custom_score_fn = None
|
|
72
|
+
if args.custom_score_file is not None:
|
|
73
|
+
try:
|
|
74
|
+
custom_score_fn = load_custom_score_from_file(
|
|
75
|
+
args.custom_score_file, function_name=args.custom_score_function)
|
|
76
|
+
except CustomScoreFileError as e:
|
|
77
|
+
print(str(e), file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
|
|
80
|
+
indexes = None
|
|
81
|
+
if args.indexes_file is not None:
|
|
82
|
+
try:
|
|
83
|
+
indexes = load_indexes_from_file(args.indexes_file)
|
|
84
|
+
except IndexesFileError as e:
|
|
85
|
+
print(str(e), file=sys.stderr)
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
message, results = run_pipeline(
|
|
89
|
+
input_folder=args.input_folder,
|
|
90
|
+
output_path=args.output_path,
|
|
91
|
+
compute_motifs=args.compute_motifs,
|
|
92
|
+
num_sites=args.num_sites,
|
|
93
|
+
motifs_path=args.motifs_path,
|
|
94
|
+
common_motifs=common_motifs,
|
|
95
|
+
optimize=not args.no_optimize,
|
|
96
|
+
mini_gc=args.mini_gc,
|
|
97
|
+
maxi_gc=args.maxi_gc,
|
|
98
|
+
method=args.method,
|
|
99
|
+
organism_name=args.organism_name,
|
|
100
|
+
indexes=indexes,
|
|
101
|
+
recombination_mode=args.recombination_mode,
|
|
102
|
+
slippage_mode=args.slippage_mode,
|
|
103
|
+
custom_score_fn=custom_score_fn,
|
|
104
|
+
custom_score_minimize=args.custom_score_minimize,
|
|
105
|
+
)
|
|
106
|
+
print(message)
|
|
107
|
+
return 0 if message == 'Success!' else 1
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == '__main__':
|
|
111
|
+
raise SystemExit(main())
|
eso/codon_usage.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Codon usage bias (CUB) tables for hosts not covered by python-codon-tables."""
|
|
2
|
+
|
|
3
|
+
from importlib import resources
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from Bio import SeqUtils
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cub_c1():
|
|
10
|
+
codon_usage_table = {
|
|
11
|
+
'A': {'GCA': 0.13718, 'GCC': 0.432097, 'GCG': 0.264336, 'GCT': 0.166387},
|
|
12
|
+
'*': {'TAA': 0.265377, 'TAG': 0.181818, 'TGA': 0.552805}, 'W': {'TGG': 1},
|
|
13
|
+
'R': {'AGA': 0.0973128, 'AGG': 0.153007, 'CGA': 0.128389, 'CGC': 0.304978, 'CGG': 0.209737,
|
|
14
|
+
'CGT': 0.106575}, 'N': {'AAC': 0.792265, 'AAT': 0.207735},
|
|
15
|
+
'D': {'GAC': 0.718222, 'GAT': 0.281778}, 'C': {'TGC': 0.710465, 'TGT': 0.289535},
|
|
16
|
+
'Q': {'CAA': 0.361039, 'CAG': 0.638961}, 'E': {'GAA': 0.250212, 'GAG': 0.749788},
|
|
17
|
+
'G': {'GGA': 0.142656, 'GGC': 0.506165, 'GGG': 0.184803, 'GGT': 0.166376},
|
|
18
|
+
'H': {'CAC': 0.650846, 'CAT': 0.349154},
|
|
19
|
+
'I': {'ATA': 0.0923118, 'ATC': 0.673554, 'ATT': 0.234134},
|
|
20
|
+
'L': {'CTA': 0.0641845, 'CTC': 0.345609, 'CTG': 0.309073, 'CTT': 0.132086, 'TTA': 0.030553,
|
|
21
|
+
'TTG': 0.118494}, 'K': {'AAA': 0.180592, 'AAG': 0.819408}, 'M': {'ATG': 1},
|
|
22
|
+
'F': {'TTC': 0.672764, 'TTT': 0.327236},
|
|
23
|
+
'P': {'CCA': 0.167124, 'CCC': 0.336275, 'CCG': 0.326667, 'CCT': 0.169935},
|
|
24
|
+
'Y': {'TAC': 0.757778, 'TAT': 0.242222},
|
|
25
|
+
'S': {'AGC': 0.234258, 'AGT': 0.0671675, 'TCA': 0.103458, 'TCC': 0.220449, 'TCG': 0.247293,
|
|
26
|
+
'TCT': 0.127375},
|
|
27
|
+
'T': {'ACA': 0.154802, 'ACC': 0.406355, 'ACG': 0.307613, 'ACT': 0.131231},
|
|
28
|
+
'V': {'GTA': 0.0871417, 'GTC': 0.460197, 'GTG': 0.289844, 'GTT': 0.162817},
|
|
29
|
+
}
|
|
30
|
+
return codon_usage_table
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cub_kompas():
|
|
34
|
+
"""Codon usage table for Komagataella phaffii (Pichia pastoris)."""
|
|
35
|
+
cub_full = {
|
|
36
|
+
'Ala': {'GCA': 0.275098, 'GCC': 0.244931, 'GCG': 0.0786548, 'GCT': 0.401316},
|
|
37
|
+
'Arg': {'AGA': 0.455639, 'AGG': 0.181255, 'CGA': 0.119597, 'CGC': 0.0506672, 'CGG': 0.0522151,
|
|
38
|
+
'CGT': 0.140626},
|
|
39
|
+
'Asn': {'AAC': 0.465812, 'AAT': 0.534188},
|
|
40
|
+
'Asp': {'GAC': 0.382762, 'GAT': 0.617238},
|
|
41
|
+
'Cys': {'TGC': 0.377246, 'TGT': 0.622754},
|
|
42
|
+
'Gln': {'CAA': 0.603655, 'CAG': 0.396345},
|
|
43
|
+
'Glu': {'GAA': 0.594953, 'GAG': 0.405047},
|
|
44
|
+
'Gly': {'GGA': 0.364371, 'GGC': 0.155831, 'GGG': 0.12228, 'GGT': 0.357518},
|
|
45
|
+
'His': {'CAC': 0.382548, 'CAT': 0.617452},
|
|
46
|
+
'Ile': {'ATA': 0.236221, 'ATC': 0.297661, 'ATT': 0.466118},
|
|
47
|
+
'Leu': {'CTA': 0.122977, 'CTC': 0.0842546, 'CTG': 0.152862, 'CTT': 0.170539, 'TTA': 0.178334,
|
|
48
|
+
'TTG': 0.291033},
|
|
49
|
+
'Lys': {'AAA': 0.519264, 'AAG': 0.480736},
|
|
50
|
+
'Met': {'ATG': 1},
|
|
51
|
+
'Phe': {'TTC': 0.42216, 'TTT': 0.57784},
|
|
52
|
+
'Pro': {'CCA': 0.378486, 'CCC': 0.180179, 'CCG': 0.102394, 'CCT': 0.338942},
|
|
53
|
+
'Ser': {'AGC': 0.104434, 'AGT': 0.158704, 'TCA': 0.208354, 'TCC': 0.174084, 'TCG': 0.0945539,
|
|
54
|
+
'TCT': 0.259871},
|
|
55
|
+
'Thr': {'ACA': 0.277392, 'ACC': 0.237645, 'ACG': 0.122946, 'ACT': 0.362017},
|
|
56
|
+
'Trp': {'TGG': 1},
|
|
57
|
+
'Tyr': {'TAC': 0.48627, 'TAT': 0.51373},
|
|
58
|
+
'Val': {'GTA': 0.178012, 'GTC': 0.217067, 'GTG': 0.216627, 'GTT': 0.388294},
|
|
59
|
+
'END': {'TAA': 0.399841, 'TAG': 0.339428, 'TGA': 0.260731},
|
|
60
|
+
}
|
|
61
|
+
# SeqUtils.seq1('END') returns 'X' (undefined amino acid), not '*' (stop) -
|
|
62
|
+
# without this special case, the stop-codon frequencies silently ended up
|
|
63
|
+
# filed under the wrong key and were never used for stop-codon scoring.
|
|
64
|
+
return {('*' if x == 'END' else SeqUtils.seq1(x)): cub_full[x] for x in cub_full}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_bundled_csv_cub(data_filename):
|
|
68
|
+
with resources.files("eso.data").joinpath(data_filename).open("r", encoding="utf-8") as handle:
|
|
69
|
+
df = pd.read_csv(handle)
|
|
70
|
+
|
|
71
|
+
codon_usage_table = df.groupby('aa').apply(
|
|
72
|
+
lambda x: x.set_index('codon')['freq_within_aa'].to_dict(), include_groups=False
|
|
73
|
+
).to_dict()
|
|
74
|
+
|
|
75
|
+
if '*' not in codon_usage_table:
|
|
76
|
+
codon_usage_table['*'] = {'TAA': 0.33, 'TAG': 0.33, 'TGA': 0.34}
|
|
77
|
+
|
|
78
|
+
return codon_usage_table
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cub_human_antibody_heavy_chain():
|
|
82
|
+
"""Codon usage table for human antibody heavy chain, from the iGEM 2025 dataset."""
|
|
83
|
+
return _load_bundled_csv_cub("human-antibody-heavy-chain-codon-frequencies.csv")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def cub_human_antibody_light_chain():
|
|
87
|
+
"""Codon usage table for human antibody light chain, from the iGEM 2025 dataset."""
|
|
88
|
+
return _load_bundled_csv_cub("human-antibody-light-chain-codon-frequencies.csv")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
CODON_USAGE_TABLES = {
|
|
92
|
+
'C1': cub_c1,
|
|
93
|
+
'kompas': cub_kompas,
|
|
94
|
+
'human_antibody_heavy_chain': cub_human_antibody_heavy_chain,
|
|
95
|
+
'human_antibody_light_chain': cub_human_antibody_light_chain,
|
|
96
|
+
}
|
eso/constraints.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Convert detected hotspot dataframes into DNAChisel AvoidPattern constraints,
|
|
2
|
+
respecting user-specified exclusion (locked) regions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from os import path
|
|
7
|
+
|
|
8
|
+
import dnachisel
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from eso.detection.recombination import _generate_neighbors
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _warn_unfixable_recombination_pair(start_1, end_1, start_2, end_2):
|
|
15
|
+
warnings.warn(
|
|
16
|
+
f"Recombination pair ({start_1}, {end_1}) / ({start_2}, {end_2}) has both sites inside an "
|
|
17
|
+
"exclusion (locked) region - there is no site left to mutate that would break this pair "
|
|
18
|
+
"without touching a locked region, so no correction constraint was built for it. This "
|
|
19
|
+
"recombination hotspot will survive optimization unmodified.",
|
|
20
|
+
stacklevel=3,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def has_overlap_exclusion(start, end, exclusions):
|
|
25
|
+
"""True if the (start, end) region overlaps any exclusion region.
|
|
26
|
+
|
|
27
|
+
(start, end) is exclusive-end throughout this codebase (matches Python
|
|
28
|
+
slicing, eso.sequence_utils.parse_region's output, and
|
|
29
|
+
eso.detection.recombination's output after _elongate_sites) - a region
|
|
30
|
+
ending exactly where another begins shares no actual nucleotide with it,
|
|
31
|
+
so `<=`/`>=` (not `<`/`>`) is required here to avoid treating merely
|
|
32
|
+
touching regions as overlapping (the same bug class fixed in
|
|
33
|
+
eso.detection._overlap.ranges_overlap, found independently in this
|
|
34
|
+
separate module).
|
|
35
|
+
"""
|
|
36
|
+
for ex in exclusions:
|
|
37
|
+
if end <= ex[0] or start >= ex[1]:
|
|
38
|
+
continue
|
|
39
|
+
return True
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _indel_recombinations(row, exclusions):
|
|
44
|
+
"""For an indel-type recombination pair: enforce a change in the smaller
|
|
45
|
+
region if it doesn't overlap an exclusion; otherwise enforce a change in
|
|
46
|
+
the larger region's inserted nucleotide (the only edit that doesn't
|
|
47
|
+
increase the Levenshtein distance further). If BOTH regions overlap an
|
|
48
|
+
exclusion, there is no site left that can legally be mutated - returns no
|
|
49
|
+
constraint at all (and warns), rather than the previous behavior of
|
|
50
|
+
falling through to constrain the smaller region anyway even though it
|
|
51
|
+
overlaps a locked region, directly contradicting the hard AvoidChanges
|
|
52
|
+
constraint built for that same region elsewhere (confirmed directly:
|
|
53
|
+
this could make DNAChisel's constraint-resolution retry loop drop
|
|
54
|
+
whichever of the two conflicting constraints it happened to reach first
|
|
55
|
+
- in the worst case, the user's own AvoidChanges lock).
|
|
56
|
+
"""
|
|
57
|
+
start_small, end_small, sequence_small = row.start_1, row.end_1, row.sequence_1
|
|
58
|
+
start_large, end_large, sequence_large = row.start_2, row.end_2, row.sequence_2
|
|
59
|
+
if row.len_1 > row.len_2:
|
|
60
|
+
start_small, end_small, sequence_small = row.start_2, row.end_2, row.sequence_2
|
|
61
|
+
start_large, end_large, sequence_large = row.start_1, row.end_1, row.sequence_1
|
|
62
|
+
|
|
63
|
+
if not has_overlap_exclusion(start_small, end_small, exclusions):
|
|
64
|
+
return [(start_small, end_small, sequence_small)]
|
|
65
|
+
|
|
66
|
+
if has_overlap_exclusion(start_large, end_large, exclusions):
|
|
67
|
+
_warn_unfixable_recombination_pair(start_small, end_small, start_large, end_large)
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
prefix = path.commonprefix([sequence_small, sequence_large])
|
|
71
|
+
suffix = sequence_small[len(prefix):] if len(prefix) < len(sequence_small) else ''
|
|
72
|
+
return [(start_large, end_large, prefix + nt + suffix) for nt in ['A', 'C', 'G', 'T']]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _substitution_recombinations(row, exclusions):
|
|
76
|
+
"""For a substitution-type recombination pair: enforce a change in
|
|
77
|
+
whichever region doesn't overlap an exclusion, at all its single-substitution neighbors.
|
|
78
|
+
If BOTH regions overlap an exclusion, there is no site left that can
|
|
79
|
+
legally be mutated - returns no constraint at all (and warns), rather
|
|
80
|
+
than the previous behavior of falling through to constrain region_2
|
|
81
|
+
anyway even though it overlaps a locked region (see
|
|
82
|
+
_indel_recombinations' docstring for the same bug in its sibling
|
|
83
|
+
function, and why this matters).
|
|
84
|
+
"""
|
|
85
|
+
start_1, end_1, sequence_1 = row.start_1, row.end_1, row.sequence_1
|
|
86
|
+
start_2, end_2 = row.start_2, row.end_2
|
|
87
|
+
|
|
88
|
+
region_1_excluded = has_overlap_exclusion(start_1, end_1, exclusions)
|
|
89
|
+
region_2_excluded = has_overlap_exclusion(start_2, end_2, exclusions)
|
|
90
|
+
|
|
91
|
+
if region_1_excluded and region_2_excluded:
|
|
92
|
+
_warn_unfixable_recombination_pair(start_1, end_1, start_2, end_2)
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
if region_2_excluded:
|
|
96
|
+
start_2, end_2 = start_1, end_1
|
|
97
|
+
|
|
98
|
+
substitution_neighbours, _, _ = _generate_neighbors(sequence_1)
|
|
99
|
+
return [(start_2, end_2, sub) for sub in substitution_neighbours]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def recombination_to_multiple_avoidance_sites(df, exclusion_regions):
|
|
103
|
+
"""Translate detected recombination pairs into individual sites to mutate,
|
|
104
|
+
such that mutating them breaks the Levenshtein-distance-1 relationship
|
|
105
|
+
between the pair (see _indel_recombinations / _substitution_recombinations).
|
|
106
|
+
"""
|
|
107
|
+
df_copy = df[['start_1', 'end_1', 'sequence_1', 'start_2', 'end_2', 'sequence_2']].copy()
|
|
108
|
+
df_copy.loc[:, 'len_1'] = df_copy.sequence_1.apply(len)
|
|
109
|
+
df_copy.loc[:, 'len_2'] = df_copy.sequence_2.apply(len)
|
|
110
|
+
|
|
111
|
+
recombination_sites = []
|
|
112
|
+
for ii in range(df_copy.shape[0]):
|
|
113
|
+
row = df_copy.iloc[ii]
|
|
114
|
+
if row.len_1 != row.len_2:
|
|
115
|
+
recombination_sites.extend(_indel_recombinations(row, exclusion_regions))
|
|
116
|
+
else:
|
|
117
|
+
recombination_sites.extend(_substitution_recombinations(row, exclusion_regions))
|
|
118
|
+
|
|
119
|
+
recombination_sites = sorted(set(recombination_sites))
|
|
120
|
+
return pd.DataFrame.from_records(data=recombination_sites, columns=['start', 'end', 'sequence'])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def exclusion_site_correcter(df, exclusion_regions):
|
|
124
|
+
"""Trim detected sites so they don't overlap any exclusion (locked) region.
|
|
125
|
+
|
|
126
|
+
(start, end) is exclusive-end (see has_overlap_exclusion) - a region's
|
|
127
|
+
exclusive end is itself the first *excluded* position, so trimming a site
|
|
128
|
+
to stop before an exclusion should set end = region[0] (not
|
|
129
|
+
region[0] - 1), and trimming to start after one should set
|
|
130
|
+
start = region[1] (not region[1] + 1). The old `-1`/`+1` didn't corrupt
|
|
131
|
+
the `sequence` field (it always matched the true substring at the
|
|
132
|
+
resulting start/end - just a shorter one than necessary), but it did
|
|
133
|
+
needlessly discard one usable, still-modifiable nucleotide per exclusion
|
|
134
|
+
boundary.
|
|
135
|
+
"""
|
|
136
|
+
if exclusion_regions == 'error':
|
|
137
|
+
return df
|
|
138
|
+
|
|
139
|
+
if df.empty:
|
|
140
|
+
# nothing to correct - and, separately, `df_before.apply(..., axis=1)`
|
|
141
|
+
# below can't infer a Series result from zero rows and returns an
|
|
142
|
+
# empty DataFrame instead, which then fails the `.loc[:, 'sequence'] =
|
|
143
|
+
# ...` assignment with a shape-mismatch ValueError. Confirmed directly:
|
|
144
|
+
# reachable via recombination_to_multiple_avoidance_sites now
|
|
145
|
+
# correctly returning empty when every candidate pair's sites are
|
|
146
|
+
# excluded (see eso.constraints._indel_recombinations/
|
|
147
|
+
# _substitution_recombinations) - previously latent because that path
|
|
148
|
+
# never used to return empty.
|
|
149
|
+
return df
|
|
150
|
+
|
|
151
|
+
for region in exclusion_regions:
|
|
152
|
+
df_before = df[df.start < region[0]]
|
|
153
|
+
df_before.loc[:, 'end'] = df_before.end.apply(lambda x: int(min(x, region[0])))
|
|
154
|
+
df_before.loc[:, 'sequence'] = df_before.apply(lambda row: row.sequence[:(row.end - row.start)], axis=1)
|
|
155
|
+
|
|
156
|
+
df_after = df[df.end > region[1]]
|
|
157
|
+
df_after.loc[:, 'start'] = df_after.start.apply(lambda x: int(max(x, region[1])))
|
|
158
|
+
df_after.loc[:, 'sequence'] = df_after.apply(lambda row: row.sequence[-(row.end - row.start):], axis=1)
|
|
159
|
+
|
|
160
|
+
df = pd.concat([df_before, df_after], ignore_index=True)
|
|
161
|
+
|
|
162
|
+
# keep at least one codon, so there's still something to modify
|
|
163
|
+
df = df[df.start < df.end - 2]
|
|
164
|
+
|
|
165
|
+
return df.sort_values('start').drop_duplicates().reset_index(drop=True)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def convert_df_to_constraints(df):
|
|
169
|
+
"""Convert a {sequence, start, end} dataframe of patterns-to-avoid into
|
|
170
|
+
DNAChisel AvoidPattern constraints.
|
|
171
|
+
"""
|
|
172
|
+
if df.shape[0] == 0:
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
df = df[['sequence', 'start', 'end']].drop_duplicates()
|
|
176
|
+
return [
|
|
177
|
+
dnachisel.AvoidPattern(df.loc[idx, 'sequence'], location=(int(df.loc[idx, 'start']), int(df.loc[idx, 'end'])))
|
|
178
|
+
for idx in df.index
|
|
179
|
+
]
|
eso/custom_score.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Wrap an arbitrary user-supplied scoring function as a DNAChisel objective,
|
|
2
|
+
as an alternative to eso.optimize's built-in CodonOptimize (CAI/tAI-style)
|
|
3
|
+
objective.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import importlib.util
|
|
7
|
+
import warnings
|
|
8
|
+
from os import path
|
|
9
|
+
|
|
10
|
+
import dnachisel
|
|
11
|
+
|
|
12
|
+
#: DNA alphabet used to build the dummy sequence that a freshly-loaded custom
|
|
13
|
+
#: score file is test-run against, so authoring mistakes surface immediately
|
|
14
|
+
#: with a plain-English message instead of mid-optimization, deep inside
|
|
15
|
+
#: DNAChisel's internals.
|
|
16
|
+
_VALIDATION_TEST_SEQUENCE = "ATGATGATGATGATGATGATGATGATGATG" # 30nt, multiple of 3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CustomScore(dnachisel.Specification):
|
|
20
|
+
"""DNAChisel objective that maximizes an arbitrary Python function of the
|
|
21
|
+
sequence, instead of a codon-usage table.
|
|
22
|
+
|
|
23
|
+
`score_fn` is called once on the whole scored region (the full sequence,
|
|
24
|
+
or `location`, if given) on every trial mutation during `optimize()`.
|
|
25
|
+
This is always correct, with no assumption about how the score behaves.
|
|
26
|
+
|
|
27
|
+
An earlier version of this class also supported a "windowed" mode
|
|
28
|
+
(calling score_fn per fixed-size chunk and summing), matching how the
|
|
29
|
+
built-in CAI/tAI codon-usage scoring works. It was removed: benchmarking
|
|
30
|
+
found no case where it was actually faster than this whole-sequence
|
|
31
|
+
approach (comparable at best, meaningfully slower at worst, since
|
|
32
|
+
DNAChisel's own optimizer ends up calling score_fn considerably more
|
|
33
|
+
often when the objective is chunk-localizable) - see
|
|
34
|
+
docs/detector-comparisons.md for the full investigation, including an
|
|
35
|
+
initial, incorrect benchmark that was itself corrected. Windowed mode
|
|
36
|
+
also carried a real, unpreventable correctness risk: a user-supplied
|
|
37
|
+
score_fn that doesn't genuinely decompose as a sum over independent
|
|
38
|
+
chunks (true of most real external/ML models) would silently compute a
|
|
39
|
+
different, structurally unrelated quantity, with no reliable way to
|
|
40
|
+
detect this automatically. Given it offered no confirmed benefit and a
|
|
41
|
+
real risk, it was removed rather than kept as an unverified "maybe
|
|
42
|
+
faster sometimes" option.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
score_fn
|
|
47
|
+
Callable taking a DNA sequence (str) and returning a float, higher is
|
|
48
|
+
better (pass `minimize=True` if lower is better).
|
|
49
|
+
location
|
|
50
|
+
Restrict the objective to a sub-region of the full sequence. Defaults
|
|
51
|
+
to the whole sequence.
|
|
52
|
+
minimize
|
|
53
|
+
If True, `score_fn`'s return value is negated before use, so that a
|
|
54
|
+
*lower* raw score is treated as better.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
best_possible_score = None
|
|
58
|
+
|
|
59
|
+
def __init__(self, score_fn, location=None, minimize=False, boost=1.0):
|
|
60
|
+
self.score_fn = score_fn
|
|
61
|
+
self.location = dnachisel.Location.from_data(location)
|
|
62
|
+
self.minimize = minimize
|
|
63
|
+
self.boost = boost
|
|
64
|
+
warnings.warn(
|
|
65
|
+
"CustomScore re-evaluates score_fn on the full scored region for "
|
|
66
|
+
"every trial mutation during optimize(), which can be slow for "
|
|
67
|
+
"an expensive score_fn or a long sequence.",
|
|
68
|
+
stacklevel=2,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def initialized_on_problem(self, problem, role=None):
|
|
72
|
+
return self._copy_with_full_span_if_no_location(problem)
|
|
73
|
+
|
|
74
|
+
def _score_sequence(self, sequence):
|
|
75
|
+
score = self.score_fn(sequence)
|
|
76
|
+
return -score if self.minimize else score
|
|
77
|
+
|
|
78
|
+
def evaluate(self, problem):
|
|
79
|
+
sequence = self.location.extract_sequence(problem.sequence)
|
|
80
|
+
score = self._score_sequence(sequence)
|
|
81
|
+
return dnachisel.SpecEvaluation(self, problem, score, locations=[self.location])
|
|
82
|
+
|
|
83
|
+
def localized(self, location, problem=None):
|
|
84
|
+
# The score can't be restricted to a sub-region without changing its
|
|
85
|
+
# meaning, so always re-evaluate the whole thing.
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def label_parameters(self):
|
|
89
|
+
params = []
|
|
90
|
+
if self.minimize:
|
|
91
|
+
params.append(("minimize", "True"))
|
|
92
|
+
return params
|
|
93
|
+
|
|
94
|
+
def short_label(self):
|
|
95
|
+
return "custom score"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CustomScoreFileError(Exception):
|
|
99
|
+
"""A custom-score file failed to load or didn't behave as expected.
|
|
100
|
+
|
|
101
|
+
Raised with a plain-English message aimed at someone who wrote a scoring
|
|
102
|
+
function but doesn't necessarily know Python packaging or DNAChisel -
|
|
103
|
+
the goal is that this exception's message alone is enough to fix the
|
|
104
|
+
problem, without needing to read a traceback through this codebase.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def load_custom_score_from_file(file_path, function_name='score'):
|
|
109
|
+
"""Load a user-authored scoring function from a plain Python file.
|
|
110
|
+
|
|
111
|
+
The file is expected to define a function named `function_name`
|
|
112
|
+
(default: `score`) taking a DNA sequence string and returning a number
|
|
113
|
+
(higher = better).
|
|
114
|
+
|
|
115
|
+
This is the mechanism behind `eso-optimize --custom-score-file`, and is
|
|
116
|
+
also usable directly from Python. Validates the file eagerly (missing
|
|
117
|
+
function, wrong type, or an error raised on a short test sequence) and
|
|
118
|
+
raises `CustomScoreFileError` with a message meant to be read and acted
|
|
119
|
+
on directly by whoever wrote the scoring file - not a Python expert.
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
score_fn
|
|
124
|
+
"""
|
|
125
|
+
if not path.isfile(file_path):
|
|
126
|
+
raise CustomScoreFileError(
|
|
127
|
+
f"Can't find the custom score file '{file_path}'. Check the path is correct.")
|
|
128
|
+
|
|
129
|
+
module_name = f"eso_custom_score_{abs(hash(file_path))}"
|
|
130
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
131
|
+
module = importlib.util.module_from_spec(spec)
|
|
132
|
+
try:
|
|
133
|
+
spec.loader.exec_module(module)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
raise CustomScoreFileError(
|
|
136
|
+
f"'{file_path}' could not be loaded - it has an error in it. "
|
|
137
|
+
f"The error was: {e!r}. Open the file and fix that error, then try again."
|
|
138
|
+
) from e
|
|
139
|
+
|
|
140
|
+
if not hasattr(module, function_name):
|
|
141
|
+
raise CustomScoreFileError(
|
|
142
|
+
f"'{file_path}' doesn't define a function called `{function_name}`. "
|
|
143
|
+
f"Add a function like:\n\n def {function_name}(seq):\n "
|
|
144
|
+
f"return ... # a number, higher = better\n\nat the top level of the file."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
score_fn = getattr(module, function_name)
|
|
148
|
+
if not callable(score_fn):
|
|
149
|
+
raise CustomScoreFileError(
|
|
150
|
+
f"'{function_name}' in '{file_path}' isn't a function - it's a {type(score_fn).__name__}. "
|
|
151
|
+
f"It needs to be defined with `def {function_name}(seq): ...`."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
result = score_fn(_VALIDATION_TEST_SEQUENCE)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
raise CustomScoreFileError(
|
|
158
|
+
f"Your `{function_name}` function raised an error when tested on the "
|
|
159
|
+
f"sequence '{_VALIDATION_TEST_SEQUENCE}': {e!r}. Please fix `{function_name}` in "
|
|
160
|
+
f"'{file_path}' and try again."
|
|
161
|
+
) from e
|
|
162
|
+
|
|
163
|
+
if not isinstance(result, (int, float)):
|
|
164
|
+
raise CustomScoreFileError(
|
|
165
|
+
f"Your `{function_name}` function returned a {type(result).__name__} "
|
|
166
|
+
f"({result!r}) instead of a number, when tested on '{_VALIDATION_TEST_SEQUENCE}'. "
|
|
167
|
+
f"Make sure it ends with `return <a number>`."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return score_fn
|
eso/data/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"codon","aa","freq_within_aa"
|
|
2
|
+
"AAA","K",0.17106890771625763
|
|
3
|
+
"AAC","N",0.7594714010180809
|
|
4
|
+
"AAG","K",0.8289310922837424
|
|
5
|
+
"AAT","N",0.2405285989819191
|
|
6
|
+
"ACA","T",0.12943434417186275
|
|
7
|
+
"ACC","T",0.5700403826283748
|
|
8
|
+
"ACG","T",0.21673262630339096
|
|
9
|
+
"ACT","T",0.08379264689637153
|
|
10
|
+
"AGA","R",0.43589749463791727
|
|
11
|
+
"AGC","S",0.2046131559664699
|
|
12
|
+
"AGG","R",0.1342483204173788
|
|
13
|
+
"AGT","S",0.1721907405814423
|
|
14
|
+
"ATA","I",0.17924610290070087
|
|
15
|
+
"ATC","I",0.5831481169905391
|
|
16
|
+
"ATG","M",1
|
|
17
|
+
"ATT","I",0.23760578010875996
|
|
18
|
+
"CAA","Q",0.2581683882559603
|
|
19
|
+
"CAC","H",0.6585073794740844
|
|
20
|
+
"CAG","Q",0.7418316117440398
|
|
21
|
+
"CAT","H",0.3414926205259156
|
|
22
|
+
"CCA","P",0.28782425102934217
|
|
23
|
+
"CCC","P",0.2818994998460761
|
|
24
|
+
"CCG","P",0.13544528480269685
|
|
25
|
+
"CCT","P",0.2948309643218849
|
|
26
|
+
"CGA","R",0.16089925516939307
|
|
27
|
+
"CGC","R",0.13614816992011966
|
|
28
|
+
"CGG","R",0.0883796430780141
|
|
29
|
+
"CGT","R",0.04442711677717706
|
|
30
|
+
"CTA","L",0.026206128917178287
|
|
31
|
+
"CTC","L",0.18474284070023503
|
|
32
|
+
"CTG","L",0.650439639667887
|
|
33
|
+
"CTT","L",0.0709314014054765
|
|
34
|
+
"GAA","E",0.155225868348273
|
|
35
|
+
"GAC","D",0.7665765599432305
|
|
36
|
+
"GAG","E",0.844774131651727
|
|
37
|
+
"GAT","D",0.23342344005676946
|
|
38
|
+
"GCA","A",0.1905103196159264
|
|
39
|
+
"GCC","A",0.39136229922447413
|
|
40
|
+
"GCG","A",0.17586996262300003
|
|
41
|
+
"GCT","A",0.24225741853659946
|
|
42
|
+
"GGA","G",0.22536749930782912
|
|
43
|
+
"GGC","G",0.2709422535216371
|
|
44
|
+
"GGG","G",0.33501746880940225
|
|
45
|
+
"GGT","G",0.16867277836113156
|
|
46
|
+
"GTA","V",0.07044783682952861
|
|
47
|
+
"GTC","V",0.5208259619800238
|
|
48
|
+
"GTG","V",0.3459984720004027
|
|
49
|
+
"GTT","V",0.06272772919004484
|
|
50
|
+
"TAC","Y",0.5955316210893039
|
|
51
|
+
"TAT","Y",0.4044683789106961
|
|
52
|
+
"TCA","S",0.131283339559025
|
|
53
|
+
"TCC","S",0.31696299978576203
|
|
54
|
+
"TCG","S",0.03795954842314847
|
|
55
|
+
"TCT","S",0.13699021568415234
|
|
56
|
+
"TGC","C",0.29280147822586516
|
|
57
|
+
"TGG","W",1
|
|
58
|
+
"TGT","C",0.7071985217741348
|
|
59
|
+
"TTA","L",0.01891296640686233
|
|
60
|
+
"TTC","F",0.6825539618451454
|
|
61
|
+
"TTG","L",0.04876702290236082
|
|
62
|
+
"TTT","F",0.3174460381548546
|