很多人第一次看到“他有两个吸引子!”这句话,第一反应可能是:这是在说人吗? 它看起来像个感情话题,像一句人物评价,又像某个视频文案里的悬念句。但如果是放在动力学、控制系统、物理模拟或者 AI 模型的技术语境下,我的第一反应完全不同:这句话其实是在描述一个相空间里至少存在两个稳定吸引子的系统。
吸引子是系统状态随时间演化之后,最终会被“吸住”的长期状态。它可以是一个不动点、一圈极限环,也可以是在有限区域内反复缠绕的奇怪吸引子。一个系统如果有两个吸引子,意味着从不同的初始条件出发,同一个系统最终会走进几个完全不同的长期状态。比如同一个弹簧振子,给一个初速度可能停在左侧凹槽,给另一个初速度可能停在右侧凹槽;同一个神经元回路,输入一个很接近的图片,可能收敛到两张完全不同的记忆。这时候再去理解“他有两个吸引子”,就是在说一个对象存在两条确定却不同的命运。
这篇文章就用“复现一个有双吸引子的系统”作为主任务,完整讲一遍:吸引子到底怎么理解、两个吸引子怎么被观察到、数值实验环境怎么搭、程序怎么跑、结果怎么判断,以及从“能画出两个涡卷”到“把双吸引子当作功能来用”时最容易踩的坑。适合人群有三类:一是刚开始学非线性动力学或混沌控制的学生;二是做振动分析、电路设计、传感器信号处理的工程师;三是想理解神经网络多个收敛点、损失景观多个局部极小点的算法工程师。
整篇文章所有示例围绕一个很经典、又不会太复杂的双吸引子系统:Duffing 振子。先不用急着配置复杂环境,只需要一台普通电脑、Python、numpy、scipy 和 matplotlib。CPU 就够跑完,不需要 GPU。
1. 先搞清楚“吸引子”到底在什么场景里出现
1.1 把吸引子翻译成普通工程师能听懂的词
如果只看教科书,吸引子的定义通常写成“状态空间中的一个集合,随着时间演化,邻近轨道最终都会趋向于这个集合”。这个定义严谨,但不够直观。我习惯把它理解成“系统最终停下来的套路”。
举例来说:
- 一个单摆没有外力时,最终停在竖直向下位置,这就是一个不动点吸引子。
- 一个受阻尼的弹簧振子,最终振幅衰减为零,也是不动点吸引子。
- 一个受周期驱动却没有太多阻尼的振子,最后可能做周期性往复运动,这就是极限环吸引子。
- 一个系统被三个变量驱动,运动轨迹在三维空间里绕来绕去,永远不重复却始终在一个区域内,这就是奇怪吸引子。
“他有两个吸引子”这句话,放到这种语境里,意思就更清楚了:系统有不同的归宿。对同一个方程、同一组参数,只要初始位置、初始速度有一点差别,结果可能走向完全不同的长期状态。
这类现象在工程里并不罕见。两个势阱之间来回运动的机械结构,对应不同电压保持状态的存储电路,激光器里的双稳态切换,生物神经元里的不同放电模式,都属于双吸引子或多吸引子系统。不要以为只有做混沌研究才用得上。
我见过不少做控制的朋友第一次遇到“双稳态切换”时,第一反应是代码写错了,或者初始条件给错。其实系统本身就允许两个稳定解同时存在,代码反而是对的。这一条在动手跑实验之前要先接受。
1.2 最适合入门的双吸引子模型:Duffing 振子
Duffing 方程是非线性动力学里很常见的教学模型,通常写成一个二阶微分方程:
x'' + δ x' - x + x^3 = γ cos(ω t)
这里 x 可以理解成振子的位移,x' 是速度,x'' 是加速度,δ 是阻尼系数,γ 是外驱动力的幅值,ω 是驱动频率。
这个系统的势能形状像一个倒过来的双凹槽,中间高、两边低。如果没有外力和阻尼,小球放进去会滑向左边凹槽或右边凹槽,两个最低点分别对应两个不动点。加上周期驱动后,有阻尼、有能量输入的系统不会停在静止点,而可能表现出两种不同的周期振荡。从左边初值出发的轨迹和从右边初值出发的轨迹,可以在相同的驱动频率下进入振幅、相位完全不同的两种稳定周期运动。
这就是“两个吸引子”的直观图像:系统没有被设计成只会到一个终点,而是保留了两种可能。
Duffing 模型能成为经典,不只是因为好画图。它的状态方程简单到本科生也能写成代码,但行为又丰富到能通过改变 γ、δ、ω 产生周期运动、倍周期分岔、混沌运动等多个阶段。如果你把所有参数都拉满,先不管双吸引子,光是不规则运动就可能让刚入门的人以为程序出了问题。
所以这篇文章先把范围缩小:用一组不会立刻进入混沌的参数,看两个吸引子如何随着初始条件不同而被分别触发。等理解了稳定吸引子,再往后扩展参数扫描也不迟。
2. 在本地把双吸引子系统“画”出来
2.1 环境准备:CPU 就够,关键是依赖版本别太老
这个演示不需要 GPU,也不需要集群。我建议在本地 Python 环境里操作,确保以下三个库可以用:
- numpy:做数组计算和初始条件生成。
- scipy:使用
solve_ivp或者odeint做数值积分。 - matplotlib:画相图、时间序列和分岔图。
系统上 Windows、macOS、Linux 都可以。Python 版本只要不是太老,比如 3.8 及以上,基本没有兼容问题。如果你用的是 Anaconda 全家桶,依赖一般已经装好;如果用原生 Python,需要执行:
pip install numpy scipy matplotlib建议先看一下版本:
import numpy, scipy, matplotlib print(numpy.__version__) print(scipy.__version__) print(matplotlib.__version__)scipy 版本如果低于 1.8,有些求解器参数写法可能有差异。实际遇到报错时,先确认这一步,再修改调用方式。很多“运行不起来”的问题不是模型问题,而是环境里缺少某些函数或版本太旧。
2.2 把 Duffing 方程改写成适合数值求解的一阶系统
数值积分的常见求解器都要求把高阶微分方程化成一阶微分方程组。对 Duffing 方程来说,可以定义两个状态变量:x1 = x,x2 = x'。于是:
x1' = x2 x2' = -δ x2 + x1 - x1^3 + γ cos(ω t)
这就是一个二维非自治系统,时间 t 直接出现在外力项里。写成 Python 函数大概是这样:
import numpy as np from scipy.integrate import solve_ivp def duffing(t, state, delta, gamma, omega): x1, x2 = state dx1_dt = x2 dx2_dt = -delta * x2 + x1 - x1**3 + gamma * np.cos(omega * t) return [dx1_dt, dx2_dt]注意这里的参数顺序是(t, state),因为solve_ivp会按照这种方式把时间点和状态数组传给函数。如果你之前习惯odeint的(state, t)参数顺序,这里很容易写反。
一个常见的坏习惯是:把所有参数都写成全局变量,函数内部直接读取。这样单独跑一次没问题,但后面做参数扫描时很容易出现“上次跑的结果被缓存影响”的情况。建议把delta, gamma, omega作为参数传入,这样每次调用可以明确知道用的是哪一组参数。
2.3 最小可运行代码:从两个不同初值出发
现在选择一组常见参数。为了先看到稳定周期运动而不是混沌,可以先设:
- δ = 0.15
- γ = 0.30
- ω = 1.0
这组参数在 Duffing 振子研究中属于比较温和的区间,容易观察到双稳态周期运动。再定义两个初始条件:一个从势能左侧附近出发,一个从右侧附近出发,模拟小球落在左右两个凹槽里。
delta = 0.15 gamma = 0.30 omega = 1.0 t_span = (0, 100) t_eval = np.linspace(0, 100, 20000) initial_left = [-1.0, 0.0] initial_right = [1.0, 0.0] sol_left = solve_ivp( duffing, t_span, initial_left, t_eval=t_eval, args=(delta, gamma, omega), rtol=1e-9, atol=1e-11 ) sol_right = solve_ivp( duffing, t_span, initial_right, t_eval=t_eval, args=(delta, gamma, omega), rtol=1e-9, atol=1e-11 )rtol和atol是相对误差和绝对误差容限。这里把精度设得比较高,是为了避免把数值误差误判成系统动力学行为。如果只是做快速演示,rtol=1e-6、atol=1e-8 也足够,但后续结果长图时可能看到毛刺。
积分到 t=100 不是硬性要求,但它给了系统足够长的过渡时间。如果只积分到 t=20,系统可能还处在暂态中,画出来的点迹不只包含吸引子,还包含初始状态向吸引子过渡的路径。要看清最终归宿,时间窗口至少要有几十个驱动周期。
绘图时最值得看的是相图,也就是以 x1 为横轴、x2 为纵轴画出的轨迹:
import matplotlib.pyplot as plt plt.figure(figsize=(8, 6)) plt.plot(sol_left.y[0], sol_left.y[1], label="left initial", lw=0.8) plt.plot(sol_right.y[0], sol_right.y[1], label="right initial", lw=0.8) plt.xlabel("x") plt.ylabel("v") plt.legend() plt.title("Two attractors in Duffing oscillator") plt.show()如果参数合适,左右两条轨迹最终都会收敛成一个闭合环;两个环并不会重叠,而是镜像地分布在相平面左右两侧。看到两个不重叠的极限环,就说明你确实观察到了两个吸引子。
为了避免暂态路径把图搞得混乱,另一种做法是只使用后半段数据:
start_index = 5000 plt.plot(sol_left.y[0][start_index:], sol_left.y[1][start_index:]) plt.plot(sol_right.y[0][start_index:], sol_right.y[1][start_index:])这样实际绘制的是系统已经稳定后的轨迹,更容易看清两个吸引子的“最终形状”。
3. 为什么初始条件是关键,以及参数边界怎么判断
3.1 双吸引子的核心工程含义:初值决定归途
很多人在跑通代码之后会问:为什么同一个方程,只改了一个初始位移,结果差这么多?
这就是双吸引子系统和非线性系统的典型特征:在某个参数区间内,系统不只有一个稳定解。左侧初值被左侧吸引子捕获,右侧初值被右侧吸引子捕获。两个吸引子之间有边界,在相空间里这个边界被称为稳定流形,描述系统“归向哪个吸引子”的分隔线。实际模拟中,你不需要准确算出流形位置,只需要知道靠近左侧的初值会流向左侧,靠近右侧的初值会流向右侧。
这条规律有几个实际影响:
- 设计双稳态器件时,系统会依赖“输入扰动够不够大、方向对不对”完成状态切换。不能只给时间信号,还要保证扰动幅度能跨过中间的不稳定状态。
- 做物理实验时,如果把初值恰好放在不稳定平衡点附近,微小噪声可能导致系统随机落入某个吸引子。你可能会误以为系统丢了状态,其实只是处于边界模糊区。
- 对神经网络做记忆恢复实验也一样。初始点距离哪个记忆模式更近,最终就更可能收敛到哪个模式。
所以“他有两个吸引子”这句话在工程里可以读成:对象具有双稳态记忆能力,而切换它需要足够的外部能量和正确的方向。
3.2 如何验证你看到的“两个结果”不是数值假象
我见过很多人画完图就给出结论,但代码里的问题可能很多。要确认两个吸引子真实存在,而不是求解器误差或相位模糊造成的假象,建议按下面顺序验证。
第一步,检查长时间末端状态是否稳定。取 t=80 到 t=100 这一段,计算出每个周期的 x、v 是否重复。如果数据还在缓慢漂移,说明积分时间不够或者容差过大,需要延长积分范围。
第二步,改变数值积分精度。rtol 从 1e-6 调整到 1e-10,观察轨迹是否发生明显偏移。如果两条不同精度的轨迹长时间后仍能重合,结果可信度提高。
第三步,改变时间步长。solve_ivp使用自适应步长,一般不用手动指定,但你可以直接指定max_step,例如max_step=0.05,看看是否需要更密的时间输出。如果输出时间间隔太稀疏,相图会变成带刺的多边形,看起来像多条线,实际只是采样点不够。
第四步,分别跑两个初始条件的反向验证。左侧初值跑完,把最终状态当作新初值,继续用同一个方程积分;右侧也一样。如果长时间后仍保持各自分离,说明不是暂态串扰。
第五步,检查驱动相位。外侧力是 cos(ωt),在不同初始时间开始积分可能会导致相位偏移。对比时尽量使用相同的积分窗口和相同的相位起点。
如果你发现两条轨迹一开始分离、到后期却完全重叠,那很可能只有一个吸引子,或者另一个吸引子在某些参数下已经消失。此时不要强行写“有两个吸引子”,要调整参数重新扫描。
3.3 资源占用和运行时间:低配置机器怎么控制成本
这类二维单变量系统计算量非常低。单次积分 100 秒、20000 个输出点,在普通 CPU 上通常几秒到十几秒就能跑完。显存、GPU 根本用不到。
真正的问题是参数扫描。如果你想画出分岔图,需要把 γ 从 0.1 扫描到 0.8,每个 γ 跑 50 条不同初值的轨迹,总计算量会显著增大。此时再叠加高精度数值积分,运行时间可能从几秒变成几十分钟。
遇到这种情况,建议先降低精度画趋势,找到候选区间后再用高精度复算。批量扫描时,每个文件保存一份参数 JSON,避免输出文件混在一起,这是排查时最省时间的方法。不要一上来就对上千组参数跑全场,先对一组参数做“初值扫描”才是更合理的做法。
注意:跑扫描任务前先确认磁盘空间和输出目录。默认输出文件名如果都是 result.png,后面数据会被覆盖,排查时很难定位是哪一组参数的结果。
4. 稳定性判断和常见排查链路
4.1 看到报错先不要怀疑模型,按顺序排查
如果运行代码时出现报错,按这个顺序检查比逐行改代码高效得多。
先看现象:
- 直接报 TypeError,大概率是函数参数顺序问题。
- 输出只有一条轨迹收敛,可能初始条件落进了同一个吸引域。
- 画出的曲线杂乱无章,可能参数已经进入混沌区间,或数值精度过低。
- 图像全空,可能 plt 没执行或者数组切片为空。
- 运行时间异常长,可能 t_span 设置过大、max_step 过小、rtol 过于严苛。
接着看输入:
duffing函数访问delta、gamma、omega时是否被正确传入,全局变量和局部变量是否发生冲突。t_eval和t_span的边界是否一致。如果t_span=(0,100)而t_eval只到 80,绘制后半段会不会越界。- 初始条件是否数量匹配。Duffing 系统是二维状态,只传一个标量会立刻报错。
再看环境:
- scipy 和 numpy 版本是否过旧。
- 是否在 Jupyter 里运行旧内核,代码更新后没有重启内核。
- matplotlib 后端是否配置正确,比如在无 GUI 的环境里需要提前切换成 Agg。
最后看问题是不是物理参数本身导致的。双吸引子不会在所有参数下都存在。把 γ 调得太小时,外驱动力无法平衡阻尼,系统更可能停在不动点,而不是两个周期运动。把 γ 调太大时,系统可能进入混沌,不再展示规则双稳态。所以要先用已知参数跑通,再探索边界。
4.2 如何判断当前阶段是暂态、稳态还是混沌
输出曲线杂乱时,新手最容易疑惑的问题是:系统到底是混沌,还是还没稳定?
暂态的特征是幅值在变化,但包络逐渐趋近稳定范围。能量从初始状态释放,系统需要好几个周期完成过渡。只要积分时间足够长,暂态会消失,轨迹会进入吸引子。
稳态周期运动的特征是在相图上形成一条闭合环,无论时间走多久,轨迹都在这个环上重复。从图上看是呈年轮一样的单条曲线。
混沌运动的特征则更加复杂。轨迹不会重复,但也不会发散,而是在相空间有限区域内不断折叠拉伸,画出的相图可能像一团纠缠的线但从整体看边界清晰。区间里同时存在多个奇怪吸引子的情况也有,不过它们通常不像双极限环那样一眼分左右。
对双吸引子实验来说,最稳的验证方法是记录每条轨迹的最后一个驱动周期,计算 x 的最小值和最大值。左侧吸引子的 x_min、x_max 区间和右侧吸引子区间通常差距明显。通过表格统计而不是只靠肉眼,能让判断更可复现。
我一般会把绘图前先输出一组状态统计值。例如:
left initial: x_min = -1.32 x_max = -0.44 right initial: x_min = 0.44 x_max = 1.32如果两个区间在 x 轴上有明显分离,说明两个吸引子的极限环确实不同。如果两组数值几乎一样,那很可能只看到一个吸引子,原因是参数不合适,或初值处在同一侧吸引域。
5. 从“画出两个吸引子”到“把双吸引子当作能力来用”
5.1 双吸引子能做哪些应用映射
代码跑通后,可以停在这里,已经算完成了理解目标。但如果想让这套现象进入实际系统设计,就能看到双吸引子在工程里的价值。
第一种应用是双稳态机械结构。两个稳定的平衡位置对应两个吸引子,通过控制输入把结构从一个平衡态移动到另一个平衡态。这一类结构大量出现在能量采集器、微机电开关、机械逻辑门的设计里。采集器利用双势阱结构,让微弱振动更容易触发阱间跳跃,产生更大的相对运动,从而提升能量转化效率。设计时应重点观察初始状态、激励幅度与状态跳跃概率之间的边界。
第二种应用是存储和记忆。Hopfield 网络是典型的多个吸引子模型,每个训练好的模式对应网络状态空间里的一个吸引子。当输入一个带噪声或者部分缺失的图片时,网络从该输入出发,最终落入原有吸引子。网络有几个稳定存储模式,就相当于“有几个吸引子”。从这个角度看,“他有两个吸引子”等同于系统具备两类可恢复记忆。
第三种应用是信号调制和解调。在双稳态电路里,用不同幅度的脉冲可以把系统状态翻转到另一侧,读出时通过观测系统最终停在哪一侧来判断信息是 0 还是 1。双吸引子作为最基本的信息载体,比直接固定阈值判断多了一层动力学保护:只要扰动没有大到跨越能量壁垒,状态就会自然保持。
准备做这些应用时,不要只盯代码。更重要的参数是能垒、驱动幅度、阻尼以及输入信号的持续时间。能垒过高,状态切换困难;能垒过低,噪声就能引起误翻转。双吸引子是否存在才只是第一步,真正落地要处理好“如何让系统可靠地留在希望的吸引子”和“如何可靠地完成切换”这对矛盾。
5.2 扩展实验:画一张吸引子随参数变化的分岔图
如果你希望把“他有两个吸引子”从单个样例扩展成系统规律,最合适的下一步是画分岔图。
思路是固定 δ 和 ω,让 γ 从小到大变化。对每个 γ,从一个初始条件运行较长时间,丢弃暂态部分,然后记录进入稳态后 x 在每个周期峰值处的取值。简单实现可以这样:
gamma_values = np.linspace(0.05, 0.6, 120) samples = [] for gamma in gamma_values: sol = solve_ivp( duffing, (0, 120), [1.0, 0.0], t_eval=np.linspace(0, 120, 30000), args=(delta, gamma, omega), rtol=1e-8, atol=1e-10 ) # 丢掉前 60 秒暂态 y1 = sol.y[0][sol.t > 60] t_trim = sol.t[sol.t > 60] # 在每个驱动周期附近取可能的峰值 # 实际简化方式:直接对稳定段取离散点,画散点 steady_x = y1[::10] # 每隔 10 个点采样 for xval in steady_x: samples.append([gamma, xval]) samples = np.array(samples) plt.figure(figsize=(10, 6)) plt.scatter(samples[:, 0], samples[:, 1], s=0.1, c="black") plt.xlabel("gamma") plt.ylabel("x") plt.show()需要注意,这段代码只是示意。真正的分岔图绘制应当更细致,要使用逐周期峰值检测,不能简单隔点采样,否则容易出现伪周期点。分岔图最大价值在于看到不同参数域内稳定解的数目变化:某些 γ 下只有一个闭合环,另一些 γ 下会出现两个分离支,形成代表双稳态的开关形状。
这类实验容易出现两个坑。一是扫描范围太粗,错过很窄的双稳态窗口。二是在一个 γ 值只跑单条初始条件,看不到同一参数下的另一条稳定支,误以为双吸引子不存在。正确做法是对每个 γ 至少跑两个初值,一个偏负、一个偏正,分别记录稳态区间。
注意:分岔图运行时间比单次样例长得多。如果跑完发现曲线毛糙,先检查是否丢掉了足够多的暂态数据,再考虑加密 γ 扫描。
5.3 代码之外最容易遗漏的三个细节
第一个细节是保存实验元数据。跑 100 个参数、100 组初值之后,如果只保存图形,相当于只保留了结果,没保留参数。于是排查时不知道某个图来自哪组初值、哪组阻尼和驱动幅值。正确的做法是每个运行文件夹里放一份 JSON:
{ "delta": 0.15, "gamma": 0.30, "omega": 1.00, "initial_condition": [1.0, 0.0], "t_span": 100, "rtol": 1e-9, "atol": 1e-11, "description": "right-side initial, expect right attractor" }这一步看似麻烦,却能省下大量重复定位时间。
第二个细节是结果判断不能完全依赖实时绘图。命令行、无显示环境或者 SSH 远程运行时,matplotlib.pyplot.show 可能没办法弹窗,代码不会报错但也不画图。此时要把图像保存为 PNG:
plt.savefig("duffing_two_attractors.png", dpi=150)在批量跑参数扫描时,建议每张图都包含参数信息作为文件名的一部分,避免同名覆盖。
第三个细节是逐步扩展,而不是一步到位。如果是第一次接触这个主题,只跑单个 γ、单个 δ 的样例就够了。把单条轨迹跑通,观察是否收敛,再改成两条初值对比,再改参数扫描。很多同学一上来就复制别人完整的分岔图代码,结果图是有了,代码改了任何参数都不理解会发生什么,最后遇到问题也无从下手。更稳妥的学习路线是从最简到复杂,我建议把第一次任务拆成:单条轨迹、两条初值对比、改变一个参数、画完整分岔图。每一步解决一个独立问题。
如果只是学习,默认配置通常够用;如果要长期做实验,就要把输出目录、参数文件、运行脚本和结果图按统一命名放好。真正导致实验进度卡住的,往往不是微分方程求解,而是同类文件名被覆盖、参数不知道来自哪一轮、代码环境变了但结果还按上一轮理解。这些问题看起来都很琐碎,但它们才是工程化的真实内容。