为美好的世界献上开源。
2023年九月底到国庆假期写的,程序比较简陋和概括,而且突触是双向传递的,只包括神经元的突触联系以及绝对不应期,但是有2000个神经元,适合作为多对象交互的实战案例,也适合Easy X入门,大致还是可以拿出来看一下的,而且效果比较漂亮。
程序其实是我在知乎上发的这篇文章用的,
https://zhuanlan.zhihu.com/p/659709309
完整代码在最后。先看下效果,看看是不是自己想要的。
绝对不应期信号抵消效应-2000神经元-5绝对不应期2.1代
这个程序的重点是在计算机算力有限的情况下,保证程序正常且快速地运行,重点在于算法优化。
首先,神经元,或者说以后遇到的其它程序的对象,都一定是只需要和程序内有限的其它对象交互,看似是2000个神经元,但每个神经元的“感受野”都只有周边的一些神经元,所以如果能把每个神经元的运算压缩到与几个神经元交互的话,程序的计算量就会非常小。
一个神经元到底和哪些神经元交互,当然是看距离,在一定范围内的神经元才能交互,所以神经元需要判断2000个神经元哪些在自己范围之内,但是如果每次迭代都要判断一遍,那么计算量会是惊人的2000^2次,且这是一个循环中的过程,要是每个循环都要来这么2000^2下,这就不太好了吧,反正我没试过,我写其它程序的时候遇到过这种情况,情况不太好。
但是解决方法也非常简单,既然神经元位置不变,那么每次判断的结果也是一样的,既然如此,为什么还要每次循环的时候重新判断一遍呢,所以直接给每个神经元装上一个用于记忆的数组,记住哪些神经元要和自己交互,轮到自己的时候去查看一下就好了。
神经元对象的代码:
struct Neure
{
short synnum[20] = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 };//突触编号
short synnumber = 0;//突触个数
double a = 5; //兴奋参数
short slp_time = 5; //绝对不应期时间参数
short x; //坐标
short y;
double a_next = 0; //兴奋参数缓存
};
//神经元数组
Neure n[2000];
上面的最后一行代码,神经元实例化是用一个结构形式的数组来实现的,和int a[10];这种是完全一样的道理,这样每个神经元就都有了编号,上面神经元结构的第一行,神经元突触编号“short synnum[20]”就是用来存储与自己交互的其它神经元的编号,突触个数short synnumber是和上面的突触编号配合使用的,到时候遍历次数就是这个数,后面除了坐标以外的其它参数都和绝对不应期有关,而这就是另一个重点了。
不应期,用人话说就是神经元被激发后,在短时间内不能再次被激发,跟其它不应期也是一样的,本来很简单,但是现在有个问题,在之前我写的《用C++、Easy X模拟三体运动教程》中也出现过这种问题,当时没提那个关键词,缓存,因为那个程序里也没有标准的缓存,现在专门说一说。
在神经元这里,不应期的现象在上面那个代码块里面是用兴奋参数double a来实现的,a随每次循环迭代递增,比如,当a大于等于10的时候,就处于可激发状态,当a被激发,a就回到0这个值,然后跟着循环次数继续递增,就会造成10帧的不应期。
但是如果只有一个兴奋参数,那么问题来了,在这一帧大循环内,有一个神经元的编号是100,兴奋性为9,前0到99在计算的时候,该神经元都处于不应期内,它自己是不能激发别人的,别人也不能激发它,但是现在轮到它计算了,因为按照算法,兴奋性要跟着大循环递增,它先算了下自己的兴奋性,竟然变成10了,处于兴奋状态,它又可以被别人激发和把别人激发了,所以对于101到1999编号的神经元,它又是在不应期以外的正常神经元。
这是怎么回事呢,非常简单,提前改变了自己的兴奋性,它一个人的兴奋性a的值必须要对于所有人都一样,且按照神经元的运行规律,神经元的兴奋性其实是取决于上一帧的,即此时此帧的兴奋性取决于上一帧是否被激发或是否a=9,所以要把上一帧的数据存储起来,拿来计算这一帧的兴奋性,然后在所有的神经元相互作用之前,就把神经元的兴奋性a值替换成新的。
吗?
当然不是的,我是说不必存储上一帧的所有信息,那个在所有交互之前刷新数据是必要的,既然反正都是要用上一帧算出来个参数,那为什么要保留整个上一帧,为什么不在上一帧的大循环里面顺便就算好各自的兴奋性,然后开个内存先存起来,下一帧再用不就行了吗?
这就是缓存存在的意义之一。当然缓存还有其它意义,比如说GPU刷新屏幕,是先计算好屏幕下一帧的画面,边计算边把像素数据存入缓存,然后把缓存里面的画面直接覆盖显示屏直接交互的存储。
具体到代码的编写,原则就是,等号右边只能使用实时参数,等号左边只能计算缓存参数,判断只能调用实时参数。以下是使用缓存的神经元交互函数,
//迭代计算函数
void compute()
{
for (short i = 0; i < Total; i++) //i为主角
{
n[i].a = n[i].a_next; //更新兴奋参数
//n[i].a_next = 0;
}
for (short i = 0; i < Total; i++) //i为主角。
//等号右边只能使用实时参数
//等号左边只能计算缓存参数
//判断只能调用实时参数
{
for (short q = 0; q < n[i].synnumber; q++) //计算第q个突触
{
if (n[i].slp_time == 5) //绝对不应期=5
{
if (n[n[i].synnum[q]].a == 10)
{
n[i].a_next = 10;
}
}
}
if (n[i].slp_time < 5) //绝对不应期=5
{
n[i].slp_time++;
}
if (n[i].a == 10) //绝对不应期,阈值为10
{
n[i].a_next = 5;
n[i].slp_time = 0;
}
}
}
到时候在主函数main()里面直接调用就行了,直接写上“compute();”就行了。这就是核心函数,其它配套函数难度不大,不过于解释。
当然,还是要强调一下写大程序长代码的习惯,一个是写好注释,另一个是尽量把主函数里面的细节,以函数的形式给搬到其它地方去,请欣赏主函数,
int main()
{
Position_Initialization(); //初始化位置
Synapse_Initialization(); //初始化突触参数
cout << "输入刷新时间间隔:";
cin >> dt;
initgraph(wx, wy); //窗口调整
while (true) //开始实时操作
{
n[0].a_next = 10; //一直保持这个神经元兴奋(具体实验目标要改变这一行)
drawmap(); //绘图
compute(); //迭代
Sleep(dt); //时间间隔停顿
_getch(); //等待键盘按键按下,用于逐帧查看
}
}
以上是全部的主函数代码,非常简洁,这次我都不用画流程图来说明,这个程序开发的时间跨度有将近两周半,注释和视图简洁非常关键,而且这样模块化的主函数非常容易改写和找bug,相对比较容易找出到底是哪个子函数出了问题,就像查“ikun”什么意思,字典肯定是先翻到i这里,而不是从头开始一个一个检索。
以下是完整代码。
#include <iostream>
#include<graphics.h>
#include<windows.h>
#include <ctime>
#include <cstdlib>
#include <conio.h>
using namespace std;
short wx = 1000; //窗口参数
short wy = 600;
short D = 30; //作用半径
short T = 0; //帧数
short Total = 2000;
short dt = 1; //时间间隔
//神经元结构
struct Neure
{
short synnum[20] = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 };//突触编号
//double syn[2][20] = { { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 } ,//突触强度
// { 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1 } };//突触强度缓存
short synnumber = 0;//突触个数
double a = 5; //兴奋参数
short slp_time = 5; //绝对不应期时间参数
short x; //坐标
short y;
double a_next = 0; //兴奋参数缓存
};
//神经元数组
Neure n[2000];
//坐标初始化函数
void Position_Initialization()
{
srand((unsigned)time(NULL));
for (short i = 0; i < Total; i++) //遍历所有神经元
{
n[i].x = rand() % 100 / 100.0 * wx;
n[i].y = rand() % 100 / 100.0 * wy;
}
}
//突触初始化函数
void Synapse_Initialization()
{
short q = 0; //突触编号顺序
short J = 0; //加法判断积累参数
for (short i = 0; i < Total; i++) //i为主角
{
q = 0;
J = 0;
n[i].synnumber = 0; //初始化突触个数
for (short b = 0; b < 20; b++) //初始化突触编号
{
n[i].synnum[b] = 0;
}
for (short j = 0; j < Total; j++)
//初始化突触个数和记录对象,j为配角
{
if (i != j) //排除主角自己
{
if (n[j].x < n[i].x + D) //加法判断第一步
{
J++;
}
if (n[j].x > n[i].x - D)
{
J++;
}
if (n[j].y < n[i].y + D)
{
J++;
}
if (n[j].y > n[i].y - D)
{
J++;
}
if (J == 4) //加法判断第二步
{
n[i].synnum[q] = j; //记录相互作用神经元编号
q++;
}
J = 0;
}
}
n[i].synnumber = q; //录入突触个数
}
}
//依次画出函数
/*
void draw()
{
for (short i = 0; i < Total; i++)
{
circle(n[i].x, n[i].y, n[i].a + 5);
TCHAR s[6];
_stprintf_s(s, _T("%d"), i); //打印编号
outtextxy(n[i].x, n[i].y, s);
_stprintf_s(s, _T("%d"), (short)n[i].a); //打印兴奋参数
outtextxy(n[i].x + 20, n[i].y, s);
_stprintf_s(s, _T("%d"), (short)n[i].synnumber); //打印突触参数
outtextxy(n[i].x, n[i].y + 15, s);
_stprintf_s(s, _T("%d"), T); //打印帧数
outtextxy(0, 0, s);
}
}
*/
//迭代计算函数
void compute()
{
for (short i = 0; i < Total; i++) //i为主角
{
n[i].a = n[i].a_next; //更新兴奋参数
//n[i].a_next = 0;
/*for (short q = 0; q < n[i].synnumber; q++) //更新突触参数
{
n[i].syn[0][q] = n[i].syn[1][q];
}*/
}
for (short i = 0; i < Total; i++) //i为主角。
//等号右边只能使用实时参数
//等号左边只能计算缓存参数
//判断只能调用实时参数
{
for (short q = 0; q < n[i].synnumber; q++) //计算第q个突触
{
/*n[i].syn[1][q] = n[i].syn[0][q] + n[n[i].synnum[q]].a * 5;
//强化参数0.5,遗忘参数0.1
n[i].a_next = n[i].a + n[n[i].synnum[q]].a * n[i].syn[0][q] * 0.5;
//平均参数0.5*/
if (n[i].slp_time == 5) //绝对不应期=5
{
if (n[n[i].synnum[q]].a == 10)
{
n[i].a_next = 10;
}
}
}
if (n[i].slp_time < 5) //绝对不应期=5
{
n[i].slp_time++;
}
if (n[i].a == 10) //绝对不应期,阈值为10
{
n[i].a_next = 5;
n[i].slp_time = 0;
}
}
}
//画出神经链路
void drawmap()
{
for (short i = 0; i < Total; i++) //i为主角神经元
{
for (short q = 0; q < n[i].synnumber; q++)
{
line(n[i].x, n[i].y, n[n[i].synnum[q]].x, n[n[i].synnum[q]].y);
}
setfillcolor(COLORREF RGB(255-n[i].slp_time * 51, 255-n[i].slp_time * 51, 255-n[i].slp_time * 51));
fillcircle(n[i].x, n[i].y, 5);
/*
TCHAR s[6];
_stprintf_s(s, _T("%d"), i); //打印编号
outtextxy(n[i].x, n[i].y, s);
_stprintf_s(s, _T("%d"), (short)n[i].a); //打印兴奋参数
outtextxy(n[i].x + 20, n[i].y, s);
_stprintf_s(s, _T("%d"), (short)n[i].synnumber); //打印突触个数
outtextxy(n[i].x, n[i].y + 15, s);
_stprintf_s(s, _T("%d"), (short)n[i].slp_time); //打印绝对不应期参数
outtextxy(n[i].x + 20, n[i].y + 15, s);
_stprintf_s(s, _T("%d"), T); //打印帧数
outtextxy(0, 0, s);
*/
}
}
int main()
{
Position_Initialization(); //初始化
Synapse_Initialization();
cout << "输入刷新时间间隔:";
cin >> dt;
initgraph(wx, wy); //窗口调整
drawmap();
_getch(); //停顿,检查神经链路
/*
n[0].a_next = 10;
n[1].a_next = 10;
n[2].a_next = 10;
n[3].a_next = 10;
n[4].a_next = 10;
*/
while (true) //开始实时操作
{
n[0].a_next = 10;
//n[1].a_next = 10;
//n[2].a_next = 10;
//n[3].a_next = 10;
//n[4].a_next = 10;
//cleardevice();
drawmap();
compute();
Sleep(dt);
_getch(); //用于逐帧查看
T++;
}
_getch();
drawmap();
_getch();
return 0;
}
以上是完整代码,包括调试用的代码。(doge)
以下是感慨。
虽然我不是计算机相关专业的,但是我还是一直对程序抱有一些我也说不出来的东西,像是多了个分身。
程序绝对不是那么简单能写完的,算法也是自己不断琢磨和更迭出来的,上面的算法,已经是第三代的算法了,每一代算法的基本逻辑完全不一样,最开始是没有缓存的,是把神经元按照元素周期表那样摆好,这样就知道神经元只能和编号为n+1、n-1、n+8、n-8的元素,欧布,神经元相互作用,但这太计算机了,神经元是不会自己把自己摆好的,而且每个神经元还不知道有多少突触呢,然后不断否定底层的逻辑,这个否定也不是很容易,但是发现新算法还是非常好的,接受起来虽说并不那么困难,但是……
但是中国近代的新青年们都是从哪儿来的,五四运动的那些学生从哪里来的,这些新式学堂从哪儿来的,还不是清政府那些“老头”主持的洋务运动来的,洋务运动虽然失败了,但是没有洋务运动,就没有整个新民主主义革命啊,没有前三代失败的算法,是不可能有第三代的算法的。
最后就到了第三代的算法,前前后后折腾了两周半,2023年的国庆终于干完了,虽然直到现在我们大学还没开任何有关程序的课。以前觉得代码写多少多少行,多牛牪犇逼啊,现在真正写下来才知道,原来代码越少越好,算法越简单越好,能用十行代码写完程序,比多写一百行代码更厉害,特别是这种搞算法的,三行代码要是能完成相同的功能,那肯定三行代码更厉害,而且真正的代码量在最终的程序里面是体现不出来的,这个程序,算上废弃的前两代的代码,最后程序运行的代码占真正写过的代码的比例,大约不到三分之一,我之前那个三体运动的程序,比例更低,精简的代码背后是更多的代码,在无尽的注释和看不见的岁月中履行了自己的职责,甚至有很多贡献经验的代码是在之前做过的其它程序里面的,代码越少,代码越多。
还有一点啊,那就是自己突然想到一个想法,或者说遇到自己想做的这种杂七杂八的事情,虽然麻烦自己,但是是实打实地锻炼自己,这是财富,别人求都求不来的宝藏,这个东西可以带你学到一系列精彩的东西的,这在大学里面,对于提升自己创新能力和专业能力是跨代的提升,虽然不是很确定,但是这些东西以后应该会成为自己的护城河,找工作和保研什么的,都有帮助。