简介:Swarm 2.1 仿真工具面向多智能体系统与大尺度复杂系统建模的研究者、教师及开发者,提供可运行、可编译的仿真环境,用于探索智能体交互、群体行为与分布式算法。压缩包为 zip 格式,共 221 个文件,约 691KB,以 73 个 m、58 个 h、27 个 o 等源码与编译产物为主,并配有 changelog、makefile、readme、scm、exe 及 license 等构建说明和可执行文件,便于按工程结构查阅与二次开发。已有 205 人学习下载,适合作为课程实验、算法验证和项目原型的参考。读者可从中获得多智能体建模的源码框架、Boids 群飞与 FIPA-ACL 通信等经典模型线索、可视化观察入口以及版本变更记录,从而较快理解 Swarm 的目录组织、编译方式与仿真流程,并据此开展交通管理、社交网络分析或生态系统建模等场景的扩展研究。
1. 拿到 swarm2.1 仿真工具,先想清楚它适合做哪类仿真
有人拿到 swarm 2.1 这套仿真库,第一反应是找个 demo 改两个参数,把图跑出来交差。真到要交付结论时才发现,图是有了,却解释不了曲线为什么长这样——调度是先于数据发生的,模型层和观察层在资源包里本来就是两套东西,揉在一起改,等于把黑匣子焊死。swarm 仿真解决的是这一类问题:一群自主个体、局部交互规则、需要观察宏观涌现。它适合群体聚集、扩散、任务分配这类多智能体推演,不适合当连续方程求解器。手上是几十到几千个 agent、关注交互规则而非单点精度,这套工具值得花半天摸清结构;要的是流体或刚体动力学,趁早换方向。
2. 拆开资源目录:model swarm 与 observer swarm 的分层怎么落地
资源包打开之后,最容易被忽略的不是示例代码,而是目录里那条隐形的分界线。swarm 2.1 这套东西的组织方式从头到尾都在强调一件事:模型该干什么、谁来看、多久看一次,是三件独立的事。你把这三件事混在一个类里,编译当然能过,但后面所有关于结果可信度的讨论都没法展开。我一般拿到这类包,先不跑示例,先把目录结构和 import 关系看一遍,确认它有没有守住这条线。
2.1 为什么必须把模型和观察拆成两个 Swarm
swarm 2.1 里的 Swarm 不是"一群个体"的意思,而是"一个可以被激活、被调度、被嵌套的执行上下文"。整个仿真树是一棵树:最上面是顶层 Swarm,下面挂模型 Swarm 和观察者 Swarm,模型 Swarm 再往下可以挂子 Swarm 做分组。每个 Swarm 有自己的 Schedule,也就是自己的时钟。
这套结构带来的直接好处是:模型层的代码里不该出现任何 gui、probe、显示相关的类。模型只负责推进状态,观察层负责在某个时刻把这些状态读出来、写文件或者画出来。你换一种可视化方式,模型层一行都不用改;你要跑批量实验把图形界面关掉,也只是换掉顶层观察者。
反过来说,如果模型层里混进了显示代码,会出现两个后果。一是无显示环境下跑不起来,批量实验直接被堵死。二是采样频率被显示刷新频率绑架——你本来想每 10 步统计一次,结果因为刷新是每步一次,数据里全是冗余点。
判断方法很土但有效:在模型层源文件里搜gui、ProbeDisplay、GUISwarm这几个词,只要出现,就说明分层没守住。合格的资源包里,模型层的 import 只到swarm.objectbase、swarm.activity、swarm.space、swarm.defobj这几层,不会往上碰显示。
另一件事是 Zone。swarm 2.1 里所有对象都要挂在一个 Zone 上,Zone 管内存分配和对象生命周期。常见做法是在最外层创建一次Globals.env.globalZone,然后逐层往下传,不要在子 Swarm 里自己 new 一个新的 Zone。自己 new 的后果是对象被回收的时机跟你想的不一样,表现为跑了几万步之后突然空指针,而且极难复现。
2.2 环境准备:老 Java 库跑在新机器上的第一步
这套 2.1 版本的库是 Java 实现,但它的语法和 API 停留在比较早的年代,所以环境准备有几个绕不开的点。我一般按下面的顺序确认,做完再碰代码。
第一步,确认 JDK 版本。不要一上来就用最新的 JDK。这类老库大量使用早期集合接口和已废弃的写法,新版 JDK 的模块化限制会直接把反射和内部类访问卡死。稳妥的做法是装一个 Java 8 的 JDK,单独指过去,不要动系统默认的 JAVA_HOME。
# 确认当前 java 版本,必须能对上资源包里的编译要求 java -version javac -version # 如果系统默认是新版,给这个项目单独指一个 JDK 8 路径 export JAVA_HOME=/path/to/jdk8 export PATH=$JAVA_HOME/bin:$PATH # 再验一次,确保 javac 和 java 是同一套 which java javac这里的逻辑是:java和javac必须来自同一个 JDK。混用是最常见的翻车点,现象是编译通过、运行时报版本不匹配或者找不到主类,你会以为是代码问题,其实是路径问题。参数上没什么可调的,就是把JAVA_HOME指向那个独立的 JDK 目录,用完这个终端窗口再关掉,不影响系统其它工具。
第二步,找到 jar 的位置并写进环境变量。资源包里通常有一个share/swarm/或lib/目录,里面放着主 jar,同目录下还可能有一个 native 库目录。这两个路径都要记下来。
# 按你解压后的实际路径改,这里用占位符 export SWARM_HOME=/path/to/swarm2.1 export SWARM_JAR=$SWARM_HOME/share/swarm/swarm.jar export LD_LIBRARY_PATH=$SWARM_HOME/lib:$LD_LIBRARY_PATH # 验证 jar 是否可读,读不到就是路径写错了 ls -l $SWARM_JARLD_LIBRARY_PATH那一行是给底层库用的。swarm 2.1 的部分功能依赖本地库,如果只配了 classpath 没配这个,编译期一切正常,运行到初始化那一步才报错,错误信息还很不友好。这一步属于典型的"提前五分钟省下两小时"。
2.3 最小可编译骨架:一个不依赖 GUI 的模型层
环境通了之后,别急着跑完整示例,先自己写一个能被编译、能被激活、但什么都不做的空壳。这一步的目的不是产出结果,是验证你的编译命令、包结构和类路径是对的。下面是我常用的最小模型层骨架。
// ModelSwarm.java —— 纯模型层,不 import 任何 gui 相关类 import swarm.Globals; import swarm.defobj.Zone; import swarm.objectbase.Swarm; import swarm.objectbase.SwarmImpl; import swarm.activity.ActionGroup; import swarm.activity.ActionGroupImpl; import swarm.activity.Schedule; import swarm.activity.ScheduleImpl; import swarm.activity.Activity; public class ModelSwarm extends SwarmImpl { public Schedule modelSchedule; // 模型层自己的时钟 public int agentCount = 200; // 个体数量,后续从外部覆盖 public double worldSize = 100.0; // 世界边长,单位由业务自己定义 public ModelSwarm(Zone aZone) { super(aZone); // 必须向上传递 Zone,不能自己新建 } public Object buildObjects() { // 领域对象只在这里创建,此处刻意留空,先验证链路 return this; } public Object buildActions() { ActionGroup group = new ActionGroupImpl(getZone()); group.createActionTo$message(this, "step"); modelSchedule = new ScheduleImpl(getZone(), 1); // 1 表示每一步都执行 modelSchedule.at$createActionTo$message(0, group, "step"); return this; } public Activity activateIn(Swarm context) { super.activateIn(context); modelSchedule.activateIn(context); return getActivity(); } public void step() { // 每个 tick 的推进逻辑,先空着 } }这段代码的关键点有三个。getZone()返回的是父级传下来的 Zone,所有子对象都该挂在它下面,这是内存管理的基准。buildObjects和buildActions是分开的两个阶段,前者建对象、后者建调度,顺序不能颠倒,因为动作组引用的是已经存在的对象。activateIn里super必须先调,父级没激活就激活子级的调度,事件循环会直接空转。
编译命令按下面的写法走,把 classes 目录单独分出来,避免源码目录被 class 文件污染。
# 编译:classpath 只放主 jar,输出到 classes 目录 mkdir -p classes javac -encoding UTF-8 -cp "$SWARM_JAR" -d classes src/*.java # 如果没有 main 入口,先只验证编译是否通过 ls classes参数说明:-cp后面只跟主 jar 路径,不要一次塞一堆不存在的 jar 进去,路径写错时 javac 会静默忽略并报"找不到符号",很容易误判成代码问题。-d指定输出目录,方便后面打包和清理。-encoding UTF-8不能省,旧环境默认编码经常不是 UTF-8,源码里有中文注释就会编译失败。
到这一步,骨架能编译通过,就可以往下填内容了。填的顺序我建议是:先填调度,再填空间,最后填采样。反过来做的话,你会写出一堆没法复现的统计代码。
3. Schedule、Space、Probe:让仿真真正动起来的三件套
上一章只验证了链路,这一章要让仿真跑起来并产出可信数据。swarm 2.1 里真正承担这件事的是三个东西:Schedule 决定"什么时候发生",Space 决定"谁和谁相邻",Probe 决定"记录什么、多久记一次"。这三个东西的参数错一个,最后的结论就可能整个反掉,而且错得很隐蔽——图还是能画出来,只是它是错的。
3.1 Schedule 的调度语义:at 和 repeat 到底谁先谁后
Schedule 的构造函数里那个整数是repeat参数,表示这个调度每隔多少个基本时间单位被触发一次。ScheduleImpl(zone, 1)是每步触发,ScheduleImpl(zone, 10)是每 10 步触发一次。这里的"步"是调度自己的时间轴,不是墙钟时间,别把它和真实秒数混在一起。
真正容易错的是at系列的语义。at$createActionTo$message(t, target, "method")的意思是:在第 t 个时间点,创建一个动作,让 target 执行 method。这个 t 是相对于这个 Schedule 被激活的时刻,不是全局绝对时间。如果你在模型层和观察层各建一个 Schedule,两个 Schedule 的 t=0 是各自激活的时刻,不在同一个基准上。
我一般会在模型层里用ScheduleImpl(zone, 1)保证每一步都推进,在观察层里用ScheduleImpl(zone, 10)或者更大,减少采样的密度。如果两个 Schedule 的 repeat 值相同但激活顺序不同,就会出现观察发生在推进之前的情况,第一帧数据永远是初始状态,看着像是"模型没动"。解决办法是在buildActions里显式地把模型层的activateIn放在观察层之前。
还有一种写法是中途动态增删动作,用ActionGroup的createActionForEach之类的方法挂多个动作。这时候要特别注意执行顺序:同一个 ActionGroup 内的动作按添加顺序执行,跨 ActionGroup 就不保证。所以有先后依赖的逻辑,宁可拆成两个 ActionGroup 按顺序激活,也不要在同一个组里赌顺序。
关于终止条件,常见做法不是在 Schedule 里设,而是在模型的 step 里判断一个计数变量,到步数之后调用结束流程。直接在调度里做"跑 N 步停"需要额外写分支,不如在 step 里加一个if (tick >= maxTicks)来得直接。
3.2 Space 空间索引与邻居查询:半径和边界一起定
swarm 2.1 提供几套空间实现,二维离散网格是Object2d配一个底层缓冲区,连续空间和双缓冲网格又是另外的类。选哪一套取决于你的个体是否需要"占据一个格子"。群体聚集类问题,个体数量多、交互只看邻居,用二维离散网格加一份对象索引是最省事的。
网格初始化时有两个参数必须一起考虑:网格边长和邻居查询半径。常见错误是网格边长按个体数量开平方来定,半径随便写个固定值,然后跑出来的聚集程度跟别人复现不出来。
// 空间初始化片段,放在 buildObjects 里 import swarm.space.Object2dImpl; import swarm.space.DblBuffer2dImpl; int gridSize = 128; // 网格边长,格子数 DblBuffer2dImpl field = new DblBuffer2dImpl(getZone(), gridSize, gridSize); Object2dImpl index = new Object2dImpl(getZone(), gridSize, gridSize); double neighborRadius = 3.0; // 查询半径,单位是格子数 // 半径必须是整数格或能明确映射到格的数值,否则查询结果依赖取整方式参数说明:gridSize决定空间分辨率,个体与网格的比例最好让"每个个体平均落到若干格子里"而不是"一对一"。一格一个个体会让邻居查询退化成正交搜索,反而更慢。neighborRadius建议取 2 到 5 之间的整数,取小数时要先想清楚取整是向上还是向下,不同取整方式会得到不同的邻居集合,结果没法对比。
边界处理是第二个坑。swarm 2.1 的网格在查询邻居时需要你自己裁掉越界的索引,它不会帮你做循环边界。如果业务需要周期性边界,越界坐标要手动取模回绕;如果是有界空间,越界部分直接跳过。两种方式的宏观结果完全不同,同一个初始构型能跑出两种聚集形态,所以这个选择要写进实验记录里,不能只留在代码里。
性能上,网格法的优势是查询是常数时间,前提是每个格子里的对象数量不爆炸。如果局部密度极高,单个格子里塞了几百个对象,每次查询都要线性扫,整体复杂度就退化了。判断方法是偶尔打印一次"最大格子占用数",它随步数增长得很快,说明需要加粒度或者换实现。
3.3 Probe 采样与结果落盘:别在观察层做计算
Probe 在 swarm 2.1 里是读取对象属性的机制,配合 ProbeMap 可以把指定成员变量暴露出来,观察层通过这些接口采样,不用直接访问模型对象的内部字段。这套设计的本意是解耦,但实际用起来经常被绕过——很多人图省事,观察层直接持有模型对象的引用,读字段、算指标、写文件一条龙。
直接读字段的后果是:模型层一旦改动字段名或者类型,观察层的编译就崩了,而且崩的地方很分散。用 Probe 的话,改动集中在一个映射文件或一段注册代码里,影响面可控。所以我一般要求观察层只通过 Probe 拿数据,指标计算也放在观察层,但计算所需的原始数据必须来自 Probe 而不是字段直读。
采样落盘这块,最实用的格式是 CSV:一行一个时间步,一列一个指标。不要用带格式的表格输出,后面做参数扫描、批量汇总的时候,CSV 是最省事的。写文件时注意两件事:一是每写若干行就 flush 一次,否则进程被中断时文件是空的;二是文件名里带上关键参数,比如种子和网格边长,否则跑十几组之后你会分不清哪个文件对应哪组配置。
// 观察层的采样动作,挂在每 N 步触发一次的 Schedule 上 import java.io.FileWriter; import java.io.PrintWriter; private PrintWriter out; public void sample() { if (out == null) { try { // 文件名带参数,避免多组实验混淆 out = new PrintWriter(new FileWriter("result_seed" + seed + "_g" + gridSize + ".csv")); out.println("tick,agent_count,mean_neighbors,cluster_index"); } catch (Exception e) { throw new RuntimeException("无法创建结果文件", e); } } // 指标全部在观察层算,模型层只提供原始状态 out.println(tick + "," + count + "," + meanNb + "," + clusterIdx); out.flush(); // 必须 flush,否则中断时文件为空 }逻辑说明:out只在第一次采样时创建,之后复用,避免每步开关文件。表头在第 0 步写一次,指标列固定顺序,方便后面用脚本解析。flush是血泪经验,不加的话仿真跑了两小时被意外中断,打开文件发现只剩表头。
参数上可调的是采样间隔。间隔太小数据量大、后面分析慢;间隔太大可能错过快速变化的过程。经验值是被观察现象的典型时间尺度的十分之一到二十分之一。比如聚集现象在 300 步左右成型,采样间隔取 10 到 20 即可,没必要每步都记。
4. 参数怎么定:网格分辨率、调度步长与随机种子
参数这件事,不是"调到一个好看的数",而是"定一组能写进报告、别人能照着重跑的数"。swarm 2.1 的好处是所有关键参数都在代码里显式出现,没有隐藏在配置文件里的魔法值;坏处是这些参数彼此耦合,改一个会影响另一个的表现。这一章把最常被问到的那几个参数拆开讲,每个都给出取值理由和判断边界的方法。
4.1 网格与邻居半径:先看密度,再看分辨率
网格边长的选择不该从数字本身出发,而要从"平均每个个体占多少格子"出发。设个体数为 N,网格边长为 L,则个体密度是 N/L²。这个值小于 0.1 时,个体之间几乎不会互相碰到,聚集类现象根本不会出现;大于 1 时,多个个体挤在同一格的概率显著上升,邻居查询退化成线性扫描。
我一般的取法是让 N/L² 落在 0.1 到 0.3 之间。比如 200 个个体、边长 40 的网格,密度是 0.125,比较合适;边长 20 的网格密度是 0.5,就偏挤了。邻居半径和这个密度是配套的:半径太小,个体彼此感知不到,系统像一堆独立粒子;半径太大,整个图就退化成一个全连接的均质系统,局部交互的意义就没了。
判断半径是否合适,有个很直接的办法:跑 50 步,统计每个个体的平均邻居数量。这个数字应该是个位数。如果平均邻居数是 0 或者 1,半径太小或者密度太低;如果是几十,半径太大,你测到的其实是大尺度平均效应。把这个统计量写进日志,比事后看图猜要靠谱得多。
还有一点容易被忽略:网格边长的改变会改变边界的相对影响。小网格里边界占比大,周期边界和有界边界的结果差异非常明显;大网格里边界影响被稀释。所以如果你打算对比两组不同规模的实验,边界条件必须固定,不能一组开周期一组不开。
4.2 调度频率与时间语义:一个 tick 到底代表多少
ScheduleImpl(zone, 1)里的 1 没有任何物理意义,它只表示"相对于这个调度的时间单位"。真实含义完全取决于你给 step 里的逻辑赋予什么解释:一步代表一秒、一天,还是一个任意的迭代次数,都行,但必须在报告里写清楚。
这个数字之所以重要,是因为它和观察间隔、现象时间尺度三者必须匹配。现象在 300 步成型、观察每 10 步采一次,最后会有 30 个有效采样点,够画趋势;如果现象 30 步就结束了、观察每 10 步采一次,只剩 3 个点,你什么都看不出来,还会误以为系统没变化。
另一种常见做法是让模型层每步推进,观察层也每步采样,然后在后处理阶段再降采样。这种做法数据量大但不会丢信息,适合前期探索阶段。等时间尺度摸清楚了,再把观察间隔调大,数据文件能小一个数量级。
要提醒的是:调度频率和计算开销是线性关系。把观察间隔从 10 调到 1,采样和写文件的时间大约翻十倍,仿真总时长可能从半小时变成几小时。前期探索可以密一点,正式批量跑的时候一定要调回来。
| 参数 | 常见取值 | 直接影响 | 何时该调 |
|---|---|---|---|
| 网格边长 | 40 到 256 | 空间分辨率与单格占用 | 平均邻居数为 0 或超过 30 |
| 邻居半径 | 2 到 5(整数格) | 交互连通度 | 平均邻居数偏离个位数 |
| 模型调度 repeat | 1 | 推进粒度 | 一般不动,动了就没法对比 |
| 观察调度 repeat | 10 到 50 | 数据密度与耗时 | 采样点少于 20 个 |
| 个体数量 | 100 到 2000 | 密度与统计稳定性 | 结果方差过大 |
| 随机种子 | 任意固定整数 | 结果可复现性 | 每次实验都必须显式给 |
这张表不用背,但建议贴在实验记录的第一页。每次开新实验,先把表里六个数写下来,再去改代码里的值。没有这六个数的实验,等于没做。
4.3 随机种子:可复现性只有一个开关
随机种子是这套工具里最不起眼、代价最大的一个参数。固定种子,同样的代码跑两次,结果完全一致,你才有资格说"这个差异来自参数改动"。不固定种子,两次结果不一样,你永远分不清是参数生效了还是随机波动。
固定种子的方式在不同入口下略有差别。命令行启动的常见做法是工具自身支持一个种子参数,在初始化阶段统一设置全局随机源;代码里启动的做法是初始化之后立刻给全局随机生成器设种子。两种方式不能混用,混用的结果是有的随机源被固定、有的没被固定,表现得"大部分时候可复现,偶尔不一样",这种最难查。
// 启动阶段统一设种子,所有随机调用都走同一个生成器实例 Globals.env.initSwarm(args); // 先完成环境初始化 int seed = 12345; // 从命令行或配置读,不要硬编码多份 Globals.env.randomGenerator.setSeed(seed); // 领域对象内部一律用 Globals.env.randomGenerator, // 不要各自 new 一个 Random,否则种子管不到它们逻辑说明:initSwarm之后才存在全局生成器,所以设种子必须放在它后面。所有需要随机数的类都从这个统一实例取,种子才真正覆盖全流程。如果某处写了new java.util.Random(),那个地方就脱离了种子的控制,属于隐性不可复现点。
可复现还有一个隐藏前提:个体的遍历顺序要稳定。如果用哈希容器存个体,遍历顺序在不同运行之间可能不同,即使种子一样,结果也会有细微差异。常见做法是换成有序容器,或者在每一步开始时按固定规则排序。这一步的代价很小,但能让"可复现"从大概齐变成真的可复现。
批量实验时,种子的处理方式有两种:所有组用同一个种子,或者每组一个不同的种子。前者的对比较干净,但结论只对一个随机实现成立;后者更接近真实方差,但需要每组多跑几次取平均。我一般先用同种子做参数敏感性,确认趋势之后再换多组种子验证稳健性,两轮都过才敢写结论。
5. 避坑与常见问题排查:五条从崩溃日志里抠出来的经验
这一章的东西不是从文档里抄的,是每次重装环境、每次换机器都会重新撞一遍的那几个。写在一起,省得下次再花半天。
现象一:编译全部通过,运行就报找不到本地库,错误信息里只有一行类似 unsatisfied link 的内容。
原因是 classpath 配了,但动态库搜索路径没配,或者配的目录和实际的库文件目录差了一层。这类资源包的目录结构往往是lib/下面还有一层平台相关子目录,直接指到lib/是不起作用的。解决方法是先find一下库文件的实际位置,再把那个具体目录加进搜索路径,并在同一个终端里验证一次。别指望写进 shell 配置文件就一劳永逸,多版本环境并存时它反而添乱。
现象二:程序启动后卡住不动,没有报错,也没有输出,CPU 占用很低。
大概率是图形界面初始化在无显示环境下阻塞了。swarm 2.1 里带 GUI 的顶层类需要图形环境,远程或者容器里跑就会卡在这一步。解决方法是把顶层换成不带 GUI 的那个类,只激活模型层和一个写文件的观察层。如果确实需要看界面,就用带转发的图形环境;只想要数据的话,彻底绕开 GUI 是更省事的选择。改完之后第一次跑建议把步数设小,确认能正常退出。
现象三:同一份代码,跑两次结果不一样,种子明明设了。
先查有没有绕过全局随机源的地方,最常见的来源是某个工具类里自己 new 了一个随机对象。再查个体容器是不是无序的,遍历顺序变化会在统计量上表现为微小抖动。这两处都排掉之后还不可复现,就看时间相关的逻辑——有没有用到当前系统时间做初始化。解决顺序就是按这三条挨个排,不要一上来就怀疑浮点精度。
现象四:网格跑到一定步数后变慢,或者个体位置出现明显异常,比如全部贴在边界上。
前者通常是局部密度增大导致单格对象数暴涨,邻居查询退化成线性扫描;后者是边界处理写错了,越界坐标没有回绕也没有跳过,直接落在了边界格上。这两个问题经常一起出现,因为密度异常本身也可能是边界处理错误导致个体被挤到边缘。排查方法是每若干步打印一次最大单格占用数和边界格上的个体数,两个数字都正常才继续往下跑。
现象五:换了个 JDK 之后,编译报一堆废弃警告,运行时报方法找不到。
老版本库里有相当一部分 API 在新 JDK 上被移除或改变签名了。别试图一条条改警告,成本极高且容易改出行为差异。正确做法是回到与资源包匹配的 JDK 版本上,把版本固定下来写进说明文档。如果确实需要在新环境里跑,优先考虑容器化隔离,而不是改库。
提示:这五条的共同点是"编译期看不出来"。所以每次换环境之后,不要直接跑长实验,先用 100 步的小规模跑一遍,确认能正常启停、能产出文件,再去跑正式实验。
6. 进阶:参数扫描脚本加一个校验指标,把结论坐实
单组实验能出图,但出不了结论。真正让人信服的做法是做一轮参数扫描,再给每组结果配一个可计算的校验指标,让"看起来聚起来了"变成"聚集指数从 0.12 升到 0.47"。这一节讲的就是这套流程里我踩过的那部分。
扫描本身用 shell 就够了,不需要写复杂的框架。核心是把参数从代码里提出来,改成启动时能覆盖的形式,然后循环调用。
#!/bin/bash # 参数扫描:固定种子做敏感性,每组独立日志 set -e for g in 64 96 128; do for r in 2 3 4; do java -cp "$SWARM_JAR:classes" Start \ -seed 12345 \ -gridSize $g \ -radius $r > "log_g${g}_r${r}.txt" 2>&1 echo "done g=$g r=$r" done doneset -e保证某一组失败时整轮停下,而不是默默跳过留下一个空洞。每个组合一个日志文件,出问题时能定位到具体是哪一组。参数通过命令行传入,代码里用默认值兜底,这样单独跑某一组也方便。
校验指标我一般用两个:平均邻居数和聚集指数。前者衡量局部连通度,后者衡量空间分布偏离均匀的程度。两个指标一起看,能排除掉"邻居很多但其实是均匀铺开"这种假聚集。指标必须由观察层在仿真结束时算并写进结果文件,不要事后拿 CSV 在别处算,那样两边口径容易不一致。
有一件事我吃过亏:扫描跑完直接看指标,发现某几组的指标明显比其他组好,就当成最优参数写进报告。回头复查才发现,那几组的仿真根本没跑满步数,中途因为网格越界抛了异常退出,指标是用残缺数据算出来的。从那以后我每次看扫描结果前,都强制先跑一遍完整性检查——确认每组的日志里都有正常结束标记,结果文件的行数符合预期,才允许自己去看指标。
希望这些能帮到你,少走两小时弯路。
本文还有配套的精品资源,点击获取