简介:这份压缩包面向具备Python基础、希望将生物信息学与深度学习结合的学习者和研究者,聚焦用机器学习设计密码子优化DNA序列、提升目标蛋白表达水平这一实际问题。包内共15个文件,约36.65MB,以Python脚本为主体,并配有JSON分词配置、H5训练模型、rnn训练记录与评估结果、序列数据压缩包及说明文档,结构上覆盖数据准备、建模和验证等环节。目前已有708人学习下载。读者可借助其中的脚本理解密码子使用偏斜、序列特征提取与替换策略,走通DNA/氨基酸编码、RNN模型训练、序列预测和生物学规则校验的完整流程;也可利用现有模型与训练记录开展对比实验、超参数调整和结果复现,并结合模型输出进行候选序列筛选,为基因工程中的表达优化提供可操作的计算实践参考。
1. 密码子优化最容易翻车的地方:把高频密码子堆满
把一条蛋白序列中所有同义密码子都换成宿主里出现频率最高的那个,表达量大概率不会上升,反而可能掉下来。这是密码子优化里最反直觉的一条经验。遗传密码有 64 个码字,61 个编码 20 种氨基酸,绝大多数氨基酸对应 2 到 6 个同义密码子,一条 300 个氨基酸的蛋白在理论上可以有 10^100 量级的写法,但真正能高表达的只落在其中极窄的一片区域。
机器学习进入这个问题的原因,是“高频密码子等于高表达”这个线性假设撑不住了。做法从挑选单个密码子,变成学一个把整条编码序列映射到表达量的打分函数,再在一个受约束的离散空间里搜索。理解这条链路,比背下几个优化工具的参数重要得多。
2. 简并性的统计学:同义密码子为什么不是等价的
2.1 从密码子表到可计算的频率向量
同义密码子的不等价性,第一层来自密码子使用偏好。同一个氨基酸的 4 个同义码字,在宿主基因组里的出现次数往往差两三倍,这种差异在高表达基因里被放大得更明显。背后的机制有几条互相纠缠的解释:tRNA 基因拷贝数与对应的氨酰-tRNA 丰度、密码子与反密码子的配对几何、以及 mRNA 二级结构对局部可及性的影响。
要把它变成可以计算的东西,第一步是把一个物种的编码序列集合统计成频率向量。真正可靠的来源是宿主的高表达基因集(比如核糖体蛋白基因),而不是全基因组,因为全基因组统计被大量低表达、非编码倾向的序列稀释了。
from collections import Counter # 标准遗传密码的同义密码子分组(DNA 形式) CODON_TABLE = { 'A': ['GCT', 'GCC', 'GCA', 'GCG'], 'R': ['CGT', 'CGC', 'CGA', 'CGG', 'AGA', 'AGG'], 'N': ['AAT', 'AAC'], 'D': ['GAT', 'GAC'], 'C': ['TGT', 'TGC'], 'Q': ['CAA', 'CAG'], 'E': ['GAA', 'GAG'], 'G': ['GGT', 'GGC', 'GGA', 'GGG'], 'H': ['CAT', 'CAC'], 'I': ['ATT', 'ATC', 'ATA'], 'L': ['TTA', 'TTG', 'CTT', 'CTC', 'CTA', 'CTG'], 'K': ['AAA', 'AAG'], 'M': ['ATG'], 'F': ['TTT', 'TTC'], 'P': ['CCT', 'CCC', 'CCA', 'CCG'], 'S': ['TCT', 'TCC', 'TCA', 'TCG', 'AGT', 'AGC'], 'T': ['ACT', 'ACC', 'ACA', 'ACG'], 'W': ['TGG'], 'Y': ['TAT', 'TAC'], 'V': ['GTT', 'GTC', 'GTA', 'GTG'], '*': ['TAA', 'TAG', 'TGA'], } # 反查:密码子 -> 氨基酸 AA_OF = {c: aa for aa, cs in CODON_TABLE.items() for c in cs} def build_freq(cds_list): """从编码序列集合统计同义密码子相对频率,返回 {codon: 组内占比}""" cnt = Counter() for cds in cds_list: for i in range(0, len(cds) - 2, 3): cnt[cds[i:i + 3]] += 1 # 按氨基酸分组求总和,再算每个码字在组内的占比 group_total = Counter() for codon, n in cnt.items(): group_total[AA_OF[codon]] += n return {c: n / group_total[AA_OF[c]] for c, n in cnt.items() if AA_OF[c] in CODON_TABLE}build_freq里最关键的一步是group_total:不是拿全基因组总次数做分母,而是按氨基酸分组归一。因为氨基酸组成本身差异很大(亮氨酸在蛋白里占比高),用全局分母会让亮氨酸的 6 个码字互相压扁,掩盖真正的偏好信号。
另一个常用统计量是 RSCU(相对同义密码子使用度),它把观察值除以“该氨基酸均分时应有的次数”,值大于 1 表示该码字被偏好。RSCU 的好处是不受氨基酸组成影响,可以直接横向比较不同基因集。缺点是它依然是逐位点、位置无关的统计,把上下文信息全部丢掉了。
2.2 CAI 与 tAI:打分的假设和它的失效边界
密码子适应指数 CAI 是最经典的单值打分,形式是几何平均:
import math def cai(cds, freq): """几何平均形式的密码子适应指数,范围 0~1""" w = [] for i in range(0, len(cds) - 2, 3): codon = cds[i:i + 3] # 单密码子(Met/Trp)和终止密码子不参与计算 if codon in ('ATG', 'TGG', 'TAA', 'TAG', 'TGA'): continue f = freq.get(codon, 0.0) fmax = max(freq.get(c, 0.0) for c in CODON_TABLE[AA_OF[codon]]) if fmax <= 0: continue w.append(f / fmax) if not w: return 0.0 # 加一个小量避免 log(0),对结果影响可忽略 return math.exp(sum(math.log(x + 1e-9) for x in w) / len(w))几何平均等价于所有位点相对适应度的对数算术平均,这个形式隐含了一个强假设:每个密码子独立贡献,且贡献可以连乘。因此 CAI 在计算上是很方便的——它把“整条序列的表达”拆成“每个位点局部适应度”,优化时可以直接贪心,每个位置独立挑最高分即可。
问题恰恰出在这个独立性上。真实翻译不是逐位点独立的事件:核糖体在 mRNA 上连续移动,前一个密码子的解码速度会影响后一个位置的核糖体密度;mRNA 在溶液里会折叠,某些位点被包在双链区里导致解码变慢;5' 端几十个核苷酸的结构直接决定起始复合物能不能上去。这些都是位置相关、上下文相关的效应,CAI 一个都表达不了。
tAI(tRNA 适应指数)比 CAI 更进一步,它用 tRNA 基因拷贝数加权,考虑密码子与反密码子的摆动配对,对“解码效率”的刻画更接近物理过程。但它的局限同样明显:tRNA 拷贝数是基因组水平的静态量,不考虑特定组织或条件下 tRNA 修饰与充电水平的变化;也不考虑 mRNA 结构。
我一般会这么理解:CAI 和 tAI 是很好的基线特征,但不适合单独作为优化目标。把它们当作机器学习模型输入的一部分,让模型自己去学权重和交互项,比手工调权重靠谱。
2.3 上下文效应:相邻密码子和 mRNA 结构才是主要因素
把这层机制拆开,至少有三类上下文效应在起作用。
第一类是密码子对偏好。某些二联密码子组合在宿主高表达基因里显著富集或显著回避,这个信号在单密码子频率上是看不出来的。一个常见解释是核糖体上同时容纳两个 tRNA 时的空间位阻,相邻 tRNA 的反密码子臂会互相碰撞,某些组合更顺畅。实际建模时,二联频率的对数比值(observed/expected)是很有效的一组特征。
第二类是 5' 端的翻译起始窗口。编码区前 10 到 15 个密码子对表达量的影响远超其长度占比,这一段决定了起始核糖体的驻留和早期延伸。把这一段全换成最高频密码子,等于让核糖体一上来就高速跑,而如果 mRNA 在这一段形成了稳定的发夹结构,40S 亚基扫描就会被卡住。这就是“优化得越彻底、表达反而越低”的常见来源。
第三类是局部结构与延伸速率。mRNA 折叠自由能低的区域,核糖体解码更顺畅。有些设计会有意在前段安排少量低频密码子,让核糖体减速形成“缓释”,避免后续密集拥堵导致碰撞和提前终止——这个现象通常叫 ramp。它说明“快”不等于“好”,翻译速率的均匀性本身就是个被优化的量。
提示:这三类效应里,结构类特征必须用专门的折叠预测工具算,不要用 GC 含量近似替代。GC 高不等于结构稳,序列排列方式的影响更大。
3. 把优化问题写成带约束的搜索:目标函数从哪来
3.1 用回归模型替代手工打分
机器学习介入的核心动作,是把目标函数从“CAI 加权和”换成一个学出来的回归器。训练数据的构造方式决定了这个模型能学什么。
一类做法是用内源基因做监督:把宿主所有基因的编码序列当输入,把 mRNA 丰度或核糖体印记的翻译效率当标签,训练一个序列到标量的模型。特征是上面那些:密码子频率、二联偏好、GC 及其窗口分布、折叠自由能、k-mer 计数、CAI/tAI 基线。
另一类做法是用大规模平行报告基因实验(MPRA)数据:把成千上万条随机或半随机的编码序列克隆到同一个启动子和 UTR 后面,测各自的表达输出。这种数据的标签更干净,因为 UTR 和拷贝数都被控制了,学到的基本就是编码序列本身的贡献。
模型的选型上,梯度提升树(如 LightGBM)在手工特征上表现稳定、可解释性好,能给出特征重要性;卷积或 Transformer 直接吃 one-hot 序列,能自动学 motif,但需要的数据量高一个数量级。我在数据量不大时会先用树模型把特征重要性摸清楚,再决定要不要上深度模型。
def score_sequence(seq, model, featurize, constraints): """模型打分减去约束惩罚,得到可比较的总分""" x = featurize(seq) # 返回一维特征向量 pred = model.predict([x])[0] # 预测 log 相对表达量 penalty = 0.0 for c in constraints: penalty += c.penalty(seq) # 每个约束返回非负惩罚值 return pred - penaltypenalty的设计要点是:硬约束(比如限制性酶切位点)应该给一个极大的惩罚值,让搜索空间里的非法解永远排不到前面;软约束(比如 GC 窗口偏离)给连续的惩罚,梯度平滑,搜索更容易收敛。如果所有约束都用硬惩罚,搜索会被困在可行域的角落;如果都用软惩罚,最终输出可能出现不可合成的序列。
3.2 硬约束清单:从能合成到能表达
约束分两层,一层是“合成得出来”,一层是“表达得对”。
合成层:GC 含量要落在供应商能合成长片段的区间内,通常全局 40% 到 60%,局部窗口更严;不能有超过一定长度的同源重复,否则组装时错配;不能有连续的同聚物(比如 6 个以上相同的碱基)。
表达层:要扫描隐蔽剪接供体/受体位点,尤其是真核表达系统;要避免内部 polyA 信号(AATAAA 及其变体)导致提前加尾和截短;要避开核糖体结合位点样序列,防止内部起始;如果下游要做克隆,还要清掉常用的酶切位点。
这些约束用代码表达出来就是一组扫描器,每个返回被命中的位置和严重程度:
import re # 各类需要回避的模式,实际项目里应该按宿主和实验设计增删 MOTIF_BLACKLIST = { 'polyA_signal': re.compile(r'AATAAA|AATTAA|AATAAT'), 'splice_donor': re.compile(r'GT[AG]AGT'), # 近似匹配,正式使用需用位置权重矩阵 'homopolymer': re.compile(r'(A{6,}|T{6,}|G{6,}|C{6,})'), } def scan_motifs(seq): """返回命中的黑名单模式及其位置""" hits = [] for name, pat in MOTIF_BLACKLIST.items(): for m in pat.finditer(seq): hits.append((name, m.start(), m.group())) return hits def gc_windows(seq, w=50): """滑窗 GC 含量列表,用于检查局部 GC 尖峰""" out = [] for i in range(0, len(seq) - w + 1): win = seq[i:i + w] out.append((win.count('G') + win.count('C')) / w) return outgc_windows返回的是逐窗口的列表,实际做惩罚时一般不直接用它,而是取最大值和标准差:最大值超标说明有局部尖峰,标准差大说明 GC 分布不均,这两个都会影响合成和折叠。
3.3 搜索:贪心、动态规划与模拟退火的取舍
约束定好之后,问题就变成了在 61^L 的离散空间里找打分最高的序列,同时氨基酸序列完全不变。这个空间大到无法枚举,只能靠启发式搜索。
贪心是最省事的:从左到右,每个位置在当前上下文下挑局部最优的密码子。它对可分解的目标函数(比如纯 CAI)是精确最优的,但一旦目标函数含窗口类特征(GC 滑窗、二联偏好),贪心就完全不适用,因为当前位置的选择会改变前面窗口的分数。
动态规划能处理一部分窗口依赖:如果依赖只跨固定的 k 个密码子,可以把状态定义成“最后 k 个密码子的组合”,做状态转移。状态数是 61^k,k=2 时约 3700,完全可算;k=3 就上万了,还能接受;再往上就爆了。二联偏好问题正好落在 k=2 这个甜点上。
模拟退火适合目标函数完全不可分解的情况,比如含 mRNA 折叠自由能(一个局部突变会改变远距离配对)。它不需要目标函数的梯度,只需要能算任意一条序列的分数,实现简单、通用性强。代价是收敛慢,需要几万到几十万次评估,每次评估如果都要跑一遍折叠预测,时间成本会很高。
| 搜索策略 | 适用目标函数 | 评估次数 | 是否会陷入局部最优 |
|---|---|---|---|
| 逐位点贪心 | 纯可分解(CAI/tAI) | O(L) | 会,且无法逃出 |
| 动态规划 | 短程窗口依赖(k≤3) | O(L·61^k) | 不会,可证最优 |
| 模拟退火 | 任意,含长程依赖 | 10^4~10^5 | 会,靠温度调度缓解 |
| 束搜索 | 模型可逐步打分 | O(L·B·61) | 会,B 越大越好 |
实际工程里常见组合是:先用动态规划拿到一个满足二联偏好的高质量初始解,再用模拟退火在上面做局部打磨,把折叠自由能这类长程项拉进来。初始解质量高,退火需要的迭代次数能降一个量级,这是很实在的性能优化。
4. Python 实现骨架:从氨基酸序列到候选 DNA
4.1 状态空间与同义密码子映射
先明确搜索空间的结构:给定氨基酸序列,每个位置的可选集合就是该氨基酸对应的同义密码子列表,大小 1 到 6 不等。甲硫氨酸和色氨酸只有唯一选择,这两个位置在搜索里是固定的,可以直接跳过,减少无效评估。
import random def init_sequence(aa_seq, freq): """按频率加权随机初始化,比均匀随机收敛更快""" seq = [] for aa in aa_seq: codons = CODON_TABLE[aa] weights = [freq.get(c, 1e-6) for c in codons] seq.append(random.choices(codons, weights=weights, k=1)[0]) return ''.join(seq) def mutable_positions(aa_seq): """返回可变的密码子下标,跳过 Met/Trp 等单密码子位点""" return [i for i, aa in enumerate(aa_seq) if len(CODON_TABLE[aa]) > 1]init_sequence用频率加权而不是均匀采样,是因为退火的起点越接近高分区,需要走的弯路越少。mutable_positions提前算好并复用,避免在主循环里反复判断——在 10 万次迭代的量级上,这点开销不能忽略。
4.2 特征提取与打分器封装
特征函数是整条链路的瓶颈,它被调用几十万次,必须写得足够快。纯 Python 循环处理一条 1000 nt 的序列要几毫秒,乘以 10 万次就是几分钟,还不算折叠预测。
from collections import Counter def kmer_counts(seq, k=3): """统计 k-mer 频次,用于重复序列惩罚和组成特征""" return Counter(seq[i:i + k] for i in range(len(seq) - k + 1)) def featurize(seq, freq, pair_freq=None, struct_fn=None): """把一条 CDS 映射成特征向量;struct_fn 为外部折叠预测接口""" feats = [] # 1) 全局组成 feats.append((seq.count('G') + seq.count('C')) / len(seq)) # 2) 第三位 GC,直接反映密码子偏好 third = seq[2::3] feats.append((third.count('G') + third.count('C')) / len(third)) # 3) 局部 GC 的极值与离散度 gw = gc_windows(seq, w=50) feats.extend([max(gw), sum(gw) / len(gw)]) # 4) 经典基线分 feats.append(cai(seq, freq)) # 5) 重复 k-mer 比例,越高越难合成 kc = kmer_counts(seq, k=8) feats.append(sum(v - 1 for v in kc.values() if v > 1) / max(1, len(seq))) # 6) 折叠自由能,依赖外部工具,缺失时用常量占位 feats.append(struct_fn(seq) if struct_fn else 0.0) return feats每个特征的物理含义要说清楚:全局 GC 影响合成与整体稳定性;第三位 GC 和密码子偏好强相关(因为简并位多落在第三位);局部 GC 极值定位结构异常区;重复 k-mer 比例直接关联合成失败率;折叠自由能刻画 5' 端可及性。
struct_fn做成可注入的接口,是为了能在不同阶段替换实现——开发阶段用常量占位跑通流程,正式评估时接序列折叠工具,避免每次调参都跑一遍外部进程。
4.3 模拟退火主循环与约束惩罚
主循环的结构很朴素:随机挑一个可变位点,换成同义密码子,算新分数,按 Metropolis 准则决定接受还是回退。
import math def anneal(aa_seq, score_fn, freq, iters=50000, t0=1.0, t_end=0.01): """模拟退火主循环,返回 (最优序列, 最优分数)""" seq = list(init_sequence(aa_seq, freq)) pos_pool = mutable_positions(aa_seq) cur = score_fn(''.join(seq)) best_seq, best_score = seq[:], cur for i in range(iters): # 几何降温:前期大步探索,后期精细收敛 T = t0 * (t_end / t0) ** (i / iters) pos = random.choice(pos_pool) old = seq[pos] codons = CODON_TABLE[aa_seq[pos]] new_codon = random.choice([c for c in codons if c != old]) seq[pos] = new_codon new = score_fn(''.join(seq)) delta = new - cur # 分数上升一定接受;下降则按 Boltzmann 概率接受 if delta >= 0 or random.random() < math.exp(delta / T): cur = new if cur > best_score: best_seq, best_score = seq[:], cur else: seq[pos] = old # 回退,保证 cur 与 seq 一致 return ''.join(best_seq), best_scoreT的调度方式是关键参数。几何降温在前期温度高时接受大量劣解,用于跨越局部平台;后期温度接近 0 时几乎只接受改进解,做局部精修。t0设得太大,前期等于随机游走,浪费评估预算;设得太小,一开始就锁死在初始解附近。
score_fn每次接收完整序列而不是增量,是为了让含窗口项和折叠项的目标函数能正常工作。如果要提速,正确做法是在score_fn内部做增量计算——只重算受影响的窗口,而不是整体重算。
注意:回退那一行
seq[pos] = old必须写在 else 分支里。如果写在循环末尾无条件执行,被接受的新密码子也会被覆盖回去,搜索看起来在跑但分数永远不动——这是个很难通过看日志发现的 bug,因为迭代次数正常增长。
5. 避坑:五条血泪记录
5.1 数据与特征侧的三类坑
现象一:优化后 CAI 从 0.72 提到 0.95,湿实验表达量掉了四成。原因是 5' 端前十几个密码子全被换成了最高频,反而让起始区形成了稳定的二级结构,翻译起始受阻。CAI 这个指标从头到尾没看结构,所以它在“优化成功”的同时把起始效率毁掉了。 解决办法是把折叠自由能从特征升级为约束:5' 端前 30 nt 的自由能不得低于设定阈值,超过就施加惩罚。同时把 5' 端单独切出来做一个子目标,不要让全局模型主导这一段。
现象二:合成订单被供应商退回,提示重复序列和局部 GC 超标。原因是搜索过程只优化表达打分,没有对同源重复施加惩罚,退火为了凑分把某些区段凑成了重复模式。 解决办法是给 k-mer 重复比例和 GC 窗口极值都加上硬惩罚,且惩罚要足够大,让任何命中黑名单的解都排在合法解之后。判定阈值提前和合成方对齐,不要等下单才发现。
现象三:模型在自建数据集上 R² 到 0.8,换一个表达宿主后预测完全失序。原因是训练数据全部来自一个宿主的基因集,模型学到的是这个宿主的密码子偏好与 tRNA 配置,换了宿主分布整体偏移。 解决办法是不要跨宿主迁移模型。换宿主就重新统计频率、重新采样训练集、重新训练。如果数据量不够,退回到只用保守特征(结构、重复、GC)加传统基线分,不要硬用迁移的模型输出。
5.2 搜索与验证侧的两类坑
现象四:模型预测分数很高,但排名前 10 的候选序列彼此只差几个碱基。原因是退火收敛后所有候选都塌缩到同一个局部最优,输出多样性为零。湿实验中一条失败就全军覆没,没有任何退路。 解决办法是换成多起点退火,或者对已找到的解做去相关:记录已经输出的解,在打分里加一个“与已有解的最小编辑距离”约束,强制输出一批分散在可行域不同区域的候选。实践上输出 10 到 20 条多样性候选,比输出 1 条“最优解”有用得多。
现象五:标签用 mRNA 丰度代替翻译效率,模型学成了预测 mRNA 稳定性。原因是 mRNA 丰度是转录、降解、翻译三者叠加的结果,用它当标签,模型抓到的很可能是序列对降解速率的影响,而不是翻译效率。 解决办法是换更接近翻译的标签——核糖体印记测出的翻译效率,或者多聚核糖体分离的分布。如果只能拿到 mRNA 丰度,至少同时用一个转录报告基因系统把转录和翻译解耦,别让标签本身带着混淆因素。
6. 设计取舍:模型的置信边界与闭环验证
这类方法最容易出问题的地方,其实不在代码,而在于把模型输出当成了物理真值。回归模型给出的是一个在训练分布内可信的相对排序,一旦候选序列的特征组合偏离训练集,预测值就没有意义了,而搜索过程恰恰会主动往特征空间的边缘跑——它就是在找“模型认为最好”的点,这个点往往落在训练数据稀疏的区域。
我一般会在流程里加三道检查。第一道是分布检查:对输出的每条候选,算它到训练集的特征距离,超出阈值的直接标记为低置信,不参与排序。第二道是模型一致性检查:用两三个结构不同的模型(比如树模型和线性模型)分别打分,如果它们的排序高度不一致,说明候选落在模型分歧区,需要人工介入或者做湿实验优先验证。第三道是约束回扫:把最终序列重新过一遍全部黑名单扫描器,确认没有任何一条约束在搜索过程中被软化成漏网之鱼。
另一个取舍是搜索预算和候选数量的分配。用同样的算力,去深挖一条序列的局部最优,还是去覆盖二十条分散的候选?在湿实验通量有限的前提下,后者通常回报更高,因为翻译效率的序列决定因素里有相当一部分是模型没抓到的,多几条候选等于多几次抽签机会。把预算从“精修单点”转向“覆盖多点”,是这类项目里我踩过最贵的坑之一。
验证层面,理想情况是做一个小的报告基因文库:把几十条候选克隆到统一启动子和 UTR 下游,测荧光或发光输出,把实测值回灌到训练集。这个闭环跑一轮,模型的排序能力通常会有明显提升,因为模型缺的往往不是算法复杂度,而是同一实验体系下的标注数据。没有条件做文库时,至少挑排序分歧最大的几条做单点验证——分歧点是最有信息量的实验对象。
最后一个常被忽略的设计点是可解释性。给出一条优化序列,如果无法回答“为什么这个位置选了这个密码子”,后续出问题时几乎没法排查。树模型的特征重要性能给到全局层面的解释,局部的 SHAP 值可以定位到具体窗口。我倾向于在输出候选的同时,附上每个位置的密码子选择依据和命中过的约束,哪怕多花一点存储和计算。
从那以后我每次交付优化序列,都会强制走一遍“分布检查—模型分歧—约束回扫”三件套,哪怕项目再赶也不跳过。这套流程拦下过好几次看起来分数漂亮、实际上根本合不出来的候选。希望帮到你。
本文还有配套的精品资源,点击获取