遗传算法的erlang实现及Master-Slave并行化模型
Our whole universe was in a hot dense state,
Then nearly fourteen billion years ago expansion started. Wait...
The Earth began to cool,
The autotrophs began to drool,
Neanderthals developed tools,
We built a wall (we built the pyramids),
Math, science, history, unraveling the mysteries,
That all started with the big bang!----<<The Big bang theory>>主题曲歌词
一、生物进化和遗传算法
不得不承认,是因为big bang及以后的一连串偶然事件,我才能坐在电脑前写这篇小文。一系列偶然的演化,导致了现在看似必然的结果,一定有某种规律在支配着演化过程。万有引力和宇宙膨胀等基本规律支配着宇宙的演化过程。生物进化,遵循的规律就是物竞天择、优胜劣汰的自然选择律。试想,不考虑约束,生物的进化完全随机的进行,人类产生并发展的几率将微乎其微。生物进化过程中,个体靠产生后代保留并发展优良的基因,靠随机的变异对自身进行提高。在自然选择的作用下,高适应度的优良个体更容易生存下去,低适应度个体面临被淘汰的命运。如此进化过程,周而复始。
遗传算法(Genetic Algorithm)正是模拟生物演化过程的一种算法,是进化算法的一个分支,非常有趣。遗传算法如今广泛应用在优化、机器学习、生物信息学等等领域。遗传算法的基本过程如下:
- 随机生成N个个体(染色体chromosomes),形成初始种群(population);
- 评估个体的适应度(fitness),选择(selection)高适应度的个体组成新的种群P';
- 个体间的交叉(crossover);
- 个体的变异(mutation);
- 结束条件满足则结束,否则重复2~5。
二、erlang实现
这里要实现的简单遗传算法使用随机均匀的方法生成初始种群,二元锦标赛选择,单点交叉和单点变异。大家可能等不急了,咱们上代码吧
-module(ga).
-export([start/3]).
-define(PC, 0.6).%交叉概率
-define(PM, 0.05).%变异概率
start(NP, NL, NGen) ->
{X,Y,Z} = now(),
random:seed(X,Y,Z),
evolve(initial_population(NP, NL), NGen).
%主控制函数
evolve(P, 0) ->
F = fitness(P, fun one_max/1),
io:format("~p~n", [F]);
evolve(P, G) ->
F = fitness(P, fun one_max/1),
NewPs = selection(P, F),
NewPc = crossover(NewPs, ?PC),
NewPm = mutation(NewPc, ?PM),
evolve(NewPm, G-1).
%产生初始种群
initial_population(NP, NL) -> [ generate_individual(NL) || _X <- lists:seq(1, NP) ].
generate_individual(NL) -> [ round(random:uniform()) || _X <- lists:seq(1, NL) ].
%计算适应度
fitness(P, Obj) ->
lists:map(Obj, P).
%选择
selection(P, F) ->
[ binary_tournament(P, F) || _X <- lists:seq(1,length(P)) ].
%交叉
crossover(P, Pc) -> crossover(P, Pc, [], length(P)).
crossover(_P, _Pc, NewP, 0) -> NewP;
crossover(P, _Pc, NewP, 1) -> [random_ind(P) | NewP];
crossover(P, Pc, NewP, Count) ->
L1 = random_ind(P),
case random:uniform() =< Pc of
true -> L2 = random_ind(P),
[Lc1, Lc2] = one_point_crossover(L1, L2),
crossover(P, Pc, [ Lc2 | [Lc1 | NewP] ], Count-2);
false -> crossover(P, Pc, [L1 | NewP], Count-1)
end.
%变异
mutation(P, Pm) ->
[ one_point_mutation(lists:nth(N,P), Pm) || N <- lists:seq(1, length(P)) ].
random_ind(P) ->
lists:nth(random:uniform(length(P)),P).
%0~N-1
zero_based_random_point(N) ->
random:uniform(N)-1.
%单点交叉
one_point_crossover(L1, L2) ->
Point = zero_based_random_point(length(L1)),
{L11, L12} = lists:split(Point, L1),
{L21, L22} = lists:split(Point, L2),
[lists:merge(L11, L22), lists:merge(L21, L12)].
%单点变异
one_point_mutation(L, Pm) ->
case random:uniform() =< Pm of
true -> Point = zero_based_random_point(length(L)),
{L1, [M | L2]} = lists:split(Point, L),
lists:merge(L1, [1-M | L2]);
false -> L
end.
one_max(L) -> lists:sum(L).
%二元锦标赛选择
binary_tournament(P, F) ->
N1 = random:uniform(length(P)),
N2 = random:uniform(length(P)),
F1 = lists:nth(N1, F),
F2 = lists:nth(N2, F),
case F1 >= F2 of
true -> lists:nth(N1, P);
false -> lists:nth(N2, P)
end.
erlang实现使用了较多的列表解析(list comprehension)操作,代码简洁了许多。这段代码解决一个简单的优化问题,二进制one-max,对一个长度为NL的二进制串L,所有位均为1时为最优解。计算适应度的函数one_max(L) -> lists:sum(L).很简单直观。算法结束的条件是运行指定的NGen代。
有同学可能要问,erlang实现,相比其他语言的实现,优势在哪里?答案是没有,至少现在还没有体现出来。熟悉遗传算法的同学都清楚,对个体/种群的操作实际上是对向量/矩阵的操作,matlab更适合干这事,还能很容易的画出美观的收敛图。那为什么要费这劲使用erlang呢?试想一下,遗传算法整个过程中,每代种群每个个体进行一次适应度计算,一共需要NGen*NP次。解决较简单的优化问题,如超级简单的one-max,计算量很小,适应度计算在整个算法计算量中占比较小。选择、交叉和变异算子所占用计算量随解决问题的不同变化较小(假设使用简单遗传算法);相对的,当问题非常复杂,适应度的计算量将急剧增大,在整个算法计算量中占比将变得非常大。计算量大时,使用并行化或者分布式计算无疑是个很好的选择,此时erlang终于找到了用武之地。
三、并行遗传算法Master-Slave模型
并行遗传算法,研究资料也已经汗牛充栋了,有兴趣的可以参考这篇综述文章。并行遗传算法有多种的实现模型,有Master-Slave模型,分布式模型等。根据以上讨论,适应度计算量随着解决问题的复杂性提高而显著增大,即仅对适应度计算部分进行并行化,算法的选择、交叉和变异(控制部分)保持不变。采用Master-Slave模型,控制部分运行在Master核,适应度计算部分将分布到各个核(Slaves)上进行。先看代码:
fitness(P, Obj) ->
pmap(Obj, P).
pmap(F, L) ->
S = self(),
Ref = make_ref(),
Pids = lists:map( fun(I) ->
spawn(fun() -> do_f(S, Ref, F, I)end)
end, L),
gather(Pids, Ref).
%进行计算并发送结果给父线程
do_f(Parent, Ref, F, I) ->
Parent ! {self(), Ref, (catch F(I))}.
%收集计算结果
gather(Pids, Ref) -> gather(Pids, Ref, []).
gather([], _Ref, Result) -> lists:reverse(Result);
gather([Pid|T], Ref, Result) ->
receive
{Pid, Ref, Ret} -> gather(T, Ref, [Ret | Result])
end.
对比之前的简单遗传算法实现,替换fitness函数即可,算法其他部分不变。pmap是并行化的map函数(该实现来自Joe Armstrong的<<programming erlang>>一书),其中使用的消息机制和轻量级线程是erlang语言两个重要构造块,pmap开启NP个轻量级线程,分别计算对应个体的适应度,至于每个线程运行在哪个核上,则由erlang虚拟机调度,线程计算完成后通过发送消息通知父线程。开启线程和发送消息所占用时间占适应度总计算量的占比越小,并行化带来的优势才会比较显著。所以对本问中使用的one-max例子,使用pmap显得大材小用了。
四、个体的命运
采用Master-Slave模型的并行遗传算法,在缩短适应度计算时间上有一定的优势,但是遗传算法的行为和简单遗传算法相比没有变化。Master掌管着一切,个体从出生到死亡,相互结合,被选择或者淘汰,都受到Master的控制,同步进行。相比简单遗传算法,个体的自由有了一定的进步,就是适应度的计算,但是过程短暂,仍在Master的可控范围内。个体注定如此吗?个体的意识能否苏醒继而掌控自己的命运?slave中是否会有斯巴达克斯的出现?且看下回继续。