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 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