摘要:遺傳演算法(Genetic Algorithm)是一種基於自然選擇過程,模擬生物進化的AI模型,它可以在模擬的生物進化過程中逐代搜尋到最優解的一種方法。本文利用遺傳演算法實現了一個簡單的程式來對課程進行排程。
本文分享自華為雲社群《如何用遺傳演算法進行智慧排課》,作者: jackwangcumt。
根據百度百科的定義,遺傳演算法(Genetic Algorithm)是一種基於自然選擇過程,模擬生物進化的AI模型,它可以在模擬的生物進化過程中逐代搜尋到最優解的一種方法。遺傳演算法不能直接對問題進行求解,而是需要藉助編碼規則,將問題中的核心要素抽象為染色體上的基因,並通過基因的交叉、變異等過程,迭代選擇優良基因進行繁殖,生成下一代,直到求得最優解或者滿意的優化解。目前遺傳演算法的使用範圍非常廣泛,常應用於運籌、機器學習、人工智慧等領域。
1 遺傳演算法過程圖解
遺傳演算法核心的任務是要通過編碼體系,給出解決方案的染色體表現規則,首先需要隨機初始化一定數量的種群(population),而種群則由一定數目的個體(individual)構成。每個個體實際上是染色體(chromosome),可以通過規則計算出適應度(fitness)。初代種群產生之後,按照優勝劣汰的進化原理,逐代進化產生出優秀的後代。
在每代進化過程中,根據個體的適應度大小來選擇個體,並藉助於自然遺傳學的遺傳運算元(genetic operators)進行交叉(crossover)和變異(mutation),產生新的種群。末代種群中的最優個體經過解碼(decoding),可以作為問題近似最優解。退出條件一般為達到最大的迭代次數,比如10000次,另外,就是適應度滿足要去,比如達到0.99。基本的流程示意圖如下所示:
2 課程編排要求
實際的課程編排,由於涉及到大量的老師、班級、教室和課程等要素,因此非常的複雜,藉助遺傳演算法也可能求不出最優解,而只是求出區域性最優解,但是利用遺傳演算法輔助課程編排仍然是一個非常好的手段。一般來說,課程編排過程中,必須滿足幾個限制條件,否則,給出的課程安排是無效的,具體說明如下:
- 同一時刻,一個教室只能開設一門課程;
- 一個教室是有座位個數限制的,上課的學生總數不能超過教室座位數;
- 同一個時刻,同一個老師或班級學生只能參與一門課程,而不能參與多個課程;
- 教室分多媒體教室和普通教室,有的課程需要多媒體教室,因此,教室配置必須滿足課程要求;
以上4條限制,都滿足的情況下,給出的課程安排才是有效的,但請注意,不一定是最優的,它並未考慮優化條件,比如同一個老師,如果在一天按照多門課程,那麼顯然有點超負荷工作,或者同一門課程,在同一天,連續開設多次,這樣對於老師和學生來說,都有點吃不消。
3 課程編排中要素資料結構
前面提到,課程編排過程中,涉及到老師、班級(學生組),教室和課程等要素,下面給出各要素的資料結構說明:
課程 : 課程物件的實現類名為Course ,其中包含課程ID和課程名稱2個欄位。
教室 : 教室物件的實現類名為Room ,其中包含教室ID、教室名稱、座位數、是否是多媒體教室這4個欄位。
教師 : 教師物件的實現類名為Professor,其中包含教師ID、教師名稱和該教室需要教授的所有課程班(CourseClass)這3個欄位。
課程班 : 課程班物件的實現類名為CourseClass ,其中包含課程授課老師、教授的課程、上課的班級、需要的座位數(多個班級人數求和)、是否需要多媒體教室和上課時長這6個欄位。此類還提供方法GroupsOverlap(CourseClass c)來判斷自己和引數c是否有班級的重疊,同理,方法 ProfessorOverlaps(CourseClass c)可判斷自己和引數c是否有老師的重疊。
班級 : 班級物件的實現類名為StudentGroup,其中包含班級ID、班級名稱、班級人數和該班級需要上的所有課程班(CourseClass)這4個欄位。
染色體表現(Chromosome Representation): 為了應用遺傳演算法,需要重點考慮如何用基因序列的方式來表示問題的解,一般來說,基因序列是一長串有序的序列,這裡可以將多維的課程安排要素通過降維方式,壓縮到一維陣列上,而陣列(後續稱為插槽Slots)的長度就是 :
一週上課天數 * 每天的上課時長數 * 教室數
舉例來說,一週上課天數假設為5,表示週一到週五,每日上課時長為12小時,比如早上從8點開始上課,晚上到20點結束。而教室數為了簡單起見,假設是2個,因此總陣列長度為 5 * 12 * 2 = 120 ,這個一維陣列中的每個元素,可以放置課程班CourseClass ,而不同的課程班組合就代表了不同的課程排課方案。排課方案可以用如下示意圖進行表示:
注意:上述一個插槽Slot代表一個小時單位,也可以表示課程的位置索引,其中可指向具體的課程班CourseClass例項,表示該插槽位有對應的課程安排。
4 適應度Fitness
基於上述的染色體表現,我們需要計算某一個個體的適應度,計算的方法如下:
- 遍歷代表染色體表現的一維陣列中的每個課程班資訊,如果同一時刻教室不存在多個課程的重疊,那麼增加適應度分值。
- 遍歷代表染色體表現的一維陣列中的每個課程班資訊,如果課程對於多媒體的要求和教室匹配,那麼增加適應度分值。
- 遍歷代表染色體表現的一維陣列中的每個課程班資訊,如果課程參與的班級總人數小於等於教室的座位數,那麼增加適應度分值。
- 遍歷代表染色體表現的一維陣列中的每個課程班資訊,如果老師同一時刻不會再多個教室進行授課,那麼增加適應度分值。
- 遍歷代表染色體表現的一維陣列中的每個課程班資訊,如果班級同一時刻不會在多個課程班進行學習,那麼增加適應度分值。
而這上述5個增加適應度分值的指標,是否滿足,可以通過額外的資料結構進行表示,即一個檢驗規則陣列表示,索引為0到4,共5個值。課程重疊,用紅色R表示,不重疊,用綠色R表示。教室是否有足夠的座位,不足用紅色S表示,否則用綠色S表示。教室是否和課程的多媒體要求匹配,不匹配用紅色L表示,否則用綠色L表示。課程班中的教師是否有重疊,重疊用紅色P表示,否則用綠色P表示。課程班中的班級是否有重疊,重疊用紅色G表示,否則用綠色G表示。而個體的適應度值為一個float型別的值,等於 :
score/ ( number_of_classes * 5)
範圍為0到1。而對於課程排課場景來說,適應度分值越高,給出的解決方案越好。因此,進化過程中的個體選擇要優選適應度分值的個體。
5 遺傳演算法模擬實現
而對於課程排課場景來說,適應度分值越高,給出的解決方案越好。因此,進化過程中的個體選擇要優選適應度分值的個體。下面給出演算法模擬的進化過程(選擇、交叉和變異)的核心程式碼片段,示例如下:
List<Schedule> offspring = new List<Schedule>(); offspring.resize(_replaceByGeneration); for (int j = 0; j < _replaceByGeneration; j++) { //隨機選擇 Schedule p1 = _chromosomes[RandomNumbers.NextNumber() % _chromosomes.Count]; Schedule p2 = _chromosomes[RandomNumbers.NextNumber() % _chromosomes.Count]; //交叉 offspring[j] = p1.Crossover(p2); //變異 offspring[j].Mutation(); }
從上述程式碼可知,後代offspring根據引數_replaceByGeneration來確定需要進化的個體大小,針對每一個進化的後代,首先通過隨機方法選擇兩個父代p1和p2,首先通過p1.Crossover(p2)獲取到交叉操作後的後代,然後在對其進行變異處理offspring[j].Mutation()。其中交叉操作核心程式碼如下:
public Schedule Crossover(Schedule parent2) { // 根據概率確定是否需要交叉操作 if (RandomNumbers.NextNumber() % 100 > _crossoverProbability) //直接返回 return new Schedule(this, false); //拷貝生成新的chromosome object Schedule n = new Schedule(this, true); int size = (int)_classes.Count; //交叉點陣列初始化 List<bool> cp = new List<bool>(); for (int k = 0; k < size; k++) { cp.Add(false); } // 隨機確定交叉位置 for (int i = _numberOfCrossoverPoints; i > 0; i--) { while (true) { int p = RandomNumbers.NextNumber() % size; if (!cp[p]) { cp[p] = true; break; } } } Dictionary<CourseClass, int>.Enumerator it1 = _classes.GetEnumerator(); Dictionary<CourseClass, int>.Enumerator it2 = parent2._classes.GetEnumerator(); //交替用父個體組合交叉生產新的個體 bool first = RandomNumbers.NextNumber() % 2 == 0; for (int i = 0; i < size; i++) { it1.MoveNext(); it2.MoveNext(); if (first) { //新增新的課程 n._classes.Add(it1.Current.Key, it1.Current.Value); for (int j = it1.Current.Key.GetDuration() - 1; j >= 0; j--) n._slots[it1.Current.Value + j].AddLast(it1.Current.Key); } else { //新增新的課程 n._classes.Add(it2.Current.Key, it2.Current.Value); for (int j = it2.Current.Key.GetDuration() - 1; j >= 0; j--) n._slots[it2.Current.Value + j].AddLast(it2.Current.Key); } //在交叉位置交替進行課程更新 if (cp[i]) first = !first; } //計算適應度 n.CalculateFitness(); //返回更好的後代 return n; }
由上述程式碼可知,其中的 _crossoverProbability表示一個交叉的概率,並不是每次呼叫交叉操作都要執行具體的交叉操作,當隨機生成的數大於設定的概率後,才進行交叉具體的操作。其中的交叉點位置也是隨機生成的,交叉點的個數通過引數_numberOfCrossoverPoints給定。交叉操作的本質是將兩個父類中所指向的課程集合進行隨機的組合交換,也就是說,交換的是課程資訊以及課程的索引位置。而變異過程相對簡單,就是對需要實行變異操作的個體,當滿足變異概率時,隨機選定一個課程並將其移動到另一個隨機選擇的插槽(Slots)中。變異過程核心程式碼如下:
public void Mutation() { //按照概率決定是否需要突變 if (RandomNumbers.NextNumber() % 100 > _mutationProbability) return; int numberOfClasses = (int)_classes.Count; int size = (int)_slots.Count; // 隨機決定突變 for (int i = _mutationSize; i > 0; i--) { int count = _classes.Count; int mpos = RandomNumbers.NextNumber() % numberOfClasses; int pos1 = 0; Dictionary<CourseClass, int>.Enumerator it = _classes.GetEnumerator(); if (mpos == 0) { it.MoveNext(); } for (; mpos > 0; it.MoveNext(), mpos--) ; pos1 = it.Current.Value; CourseClass cc1 = it.Current.Key; // 隨機確定課程的索引位置 int nr = Configuration.GetInstance().GetNumberOfRooms(); int dur = cc1.GetDuration(); int day = RandomNumbers.NextNumber() % DefineConstantsSchedule.DAYS_NUM; int room = RandomNumbers.NextNumber() % nr; int time = RandomNumbers.NextNumber() % (DefineConstantsSchedule.DAY_HOURS + 1 - dur); int pos2 = day * nr * DefineConstantsSchedule.DAY_HOURS + room * DefineConstantsSchedule.DAY_HOURS + time; for (int k = dur - 1; k >= 0; k--) { //移除不需要的課程 LinkedList<CourseClass> cl = _slots[pos1 + k]; for (LinkedList<CourseClass>.Enumerator it3 = cl.GetEnumerator(); it3.MoveNext(); ) { if (it3.Current == cc1) { cl.Remove(it3.Current); break; } } //移動課程到新的插槽位置 _slots[pos2 + k].AddLast(cc1); } // 更新課程位置 _classes[cc1] = pos2; } CalculateFitness(); }
課程排程,需要提供一些基礎的資料,比如教師資源情況、班級情況、課程情況、教室情況等。下面給出資源資料模板:
#prof id = 1 name = 李老師 #end #prof id = 2 name = 張老師 #end #prof id = 3 name = 汪老師 #end ... #course id = 1 name = Web程式設計 #end #course id = 4 name = 電子商務 #end ... #course id = 8 name = 資料庫原理 #end #room name = C101 lab = false size = 80 #end #room name = C102 lab = true size = 90 #end #group id = 1 name = 電商1班 size = 22 #end ... #group id = 4 name = 會計2班 size = 27 #end #class professor = 1 course = 1 duration = 2 group = 1 group = 2 #end ... #class professor = 12 course = 8 duration = 2 group = 3 group = 4 #end
下面給出初始化種群中的個體染色體表現型,具體程式碼如下,種群大小可通過引數給定,通過迴圈呼叫MakeNewFromPrototype()生成不同的個體,並新增到初代種群中。MakeNewFromPrototype方法核心程式碼如下:
public Schedule MakeNewFromPrototype() { //插槽個數 int size = (int)_slots.Count; //生成新的個體 chromosome Schedule newChromosome = new Schedule(this, true); //隨機獲取CourseClass資訊 LinkedList<CourseClass> c = Configuration.GetInstance().GetCourseClasses(); for (LinkedList<CourseClass>.Enumerator it = c.GetEnumerator(); it.MoveNext(); ) { //隨機獲取課程位置 int nr = Configuration.GetInstance().GetNumberOfRooms(); int dur = (it.Current).GetDuration(); int day = RandomNumbers.NextNumber() % DefineConstantsSchedule.DAYS_NUM; int room = RandomNumbers.NextNumber() % nr; int time = RandomNumbers.NextNumber() % (DefineConstantsSchedule.DAY_HOURS + 1 - dur); int pos = day * nr * DefineConstantsSchedule.DAY_HOURS + room * DefineConstantsSchedule.DAY_HOURS + time; //將CourseClass資訊放於隨機的插槽位上 for (int i = dur - 1; i >= 0; i--) newChromosome._slots[pos + i].AddLast(it.Current); //新增課程class資訊 newChromosome._classes.Add(it.Current, pos); } //計算適應度 newChromosome.CalculateFitness(); return newChromosome; }
在UI上,採用C# GDI+進行繪製,示例如下:
protected void paint(PaintEventArgs e) { string baseFile = AppDomain.CurrentDomain.BaseDirectory; string filename = baseFile + "GaSchedule.cfg"; Configuration.GetInstance().ParseFile(ref filename); Graphics gac = e.Graphics; Rectangle clientRect = e.ClipRectangle; try { this.Invoke((MethodInvoker)delegate { sx = -GetScrollPos(this.Handle, SB_HORZ); sy = -GetScrollPos(this.Handle, SB_VERT); }); } catch (Exception ex) { Console.WriteLine(ex.Message); sx = 0; sy = 0; } Color newColor = System.Drawing.Color.FromArgb(49, 147, 120); Color bzColor = System.Drawing.Color.FromArgb(49, 147, 120); Color errorColor = System.Drawing.Color.FromArgb(206, 0, 0); Brush bgBrush = System.Drawing.Brushes.White; gac.FillRectangle(bgBrush, clientRect); Font tableHeadersFont = new Font("Microsoft YaHei", 12); Font tableTextFont = new Font("Microsoft YaHei", 10); Font roomDescFont = new Font("Microsoft YaHei", 10); Font criteriaFont = new Font("Microsoft YaHei", 12); SolidBrush classBrush = new SolidBrush(Color.DarkOrchid); classBrush.Color = Color.FromArgb(255, 255, 245); SolidBrush overlapBrush = new SolidBrush(Color.DarkOrchid); overlapBrush.Color = Color.FromArgb(255, 0, 0); HatchBrush myHatchBrush = new HatchBrush(HatchStyle.BackwardDiagonal, Color.Red,Color.Transparent); int nr = Configuration.GetInstance().GetNumberOfRooms(); for (int k = 0; k < nr; k++) { for (int i = 0; i < ROOM_COLUMN_NUMBER; i++) { for (int j = 0; j < ROOM_ROW_NUMBER; j++) { int l = k % 2; int m = k / 2; if (i == 0 && j == 0) { Rectangle rect2 = new Rectangle( sx+ROOM_MARGIN_WIDTH + ROOM_TABLE_WIDTH * l, sy+ROOM_MARGIN_HEIGHT, ROOM_CELL_WIDTH, ROOM_CELL_HEIGHT); gac.DrawRectangle(Pens.Black, rect2); Rectangle rect3 = new Rectangle(rect2.X, rect2.Y + 8, rect2.Width, rect2.Height - 16); string str; str = string.Format("教室:{0}", Configuration.GetInstance().GetRoomById(k).GetName()); TextRenderer.DrawText(gac, str, roomDescFont, rect3, Color.FromArgb(0, 0, 0), TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter); } if (i == 0 && j > 0) { string str = string.Format("{0} - {1}", 8 + j - 1, 8 + j); Rectangle rect3 = new Rectangle( sx + ROOM_MARGIN_WIDTH + ROOM_TABLE_WIDTH * l , sy + ROOM_MARGIN_HEIGHT + ROOM_CELL_HEIGHT * (j), ROOM_CELL_WIDTH, ROOM_CELL_HEIGHT); TextRenderer.DrawText(gac, str, tableHeadersFont, rect3, Color.FromArgb(0, 0, 0), TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter); gac.DrawRectangle(Pens.Black, rect3); } if (j == 0 && i > 0) { string[] days = { "週一", "週二", "週三", "週四", "週五" }; Rectangle rect3 = new Rectangle( sx + ROOM_MARGIN_WIDTH + ROOM_TABLE_WIDTH * l + ROOM_CELL_WIDTH * (i), sy + ROOM_MARGIN_HEIGHT, ROOM_CELL_WIDTH, ROOM_CELL_HEIGHT); TextRenderer.DrawText(gac, days[i - 1], tableHeadersFont, rect3, Color.FromArgb(0, 0, 0), TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter); gac.DrawRectangle(Pens.Black, rect3); } } } } if (_schedule != null) { Dictionary<CourseClass, int> classes = _schedule.GetClasses(); int ci = 0; for (Dictionary<CourseClass, int>.Enumerator it = classes.GetEnumerator(); it.MoveNext(); ci += 5) { CourseClass c = it.Current.Key; int p = it.Current.Value; int t = p % (nr * DAY_HOURS); int d = p / (nr * DAY_HOURS) + 1; int r = t / DAY_HOURS; t = t % DAY_HOURS + 1; int l = r % 2; int m = r / 2; Rectangle rect = new Rectangle( sx + ROOM_TABLE_WIDTH * l + ROOM_MARGIN_WIDTH + d * ROOM_CELL_WIDTH , sy + ROOM_TABLE_HEIGHT * m + ROOM_MARGIN_HEIGHT + t * ROOM_CELL_HEIGHT , ROOM_CELL_WIDTH , c.GetDuration() * ROOM_CELL_HEIGHT); string str = string.Format("{0}\n({1})\n", c.GetCourse().GetName(), c.GetProfessor().GetName()); for (LinkedList<StudentsGroup>.Enumerator it2 = c.GetGroups().GetEnumerator(); it2.MoveNext(); ) { str += (it2.Current).GetName(); str += "/"; } str=str.TrimEnd('/'); if (c.IsLabRequired()) str += "[多媒體]"; gac.FillRectangle(classBrush, rect); gac.DrawRectangle(Pens.Black, rect); TextRenderer.DrawText(gac, str, tableTextFont, rect, Color.FromArgb(0, 0, 0), TextFormatFlags.WordBreak); if (!_schedule.GetCriteria()[ci + 0]) { bzColor = errorColor; TextRenderer.DrawText(gac, "R", tableTextFont, new Point(rect.Left, rect.Bottom - 20), bzColor); gac.FillRectangle(myHatchBrush, rect); } else { TextRenderer.DrawText(gac, "R", tableTextFont, new Point(rect.Left, rect.Bottom - 20), bzColor); } bzColor = newColor; if (!_schedule.GetCriteria()[ci + 1]) { bzColor = errorColor; TextRenderer.DrawText(gac, "S", tableTextFont, new Point(rect.Left + 10, rect.Bottom - 20), bzColor); } else { TextRenderer.DrawText(gac, "S", tableTextFont, new Point(rect.Left + 10, rect.Bottom - 20), bzColor); } bzColor = newColor; if (!_schedule.GetCriteria()[ci + 2]) { bzColor = errorColor; TextRenderer.DrawText(gac, "L", tableTextFont, new Point(rect.Left + 20, rect.Bottom -20), bzColor); } else { TextRenderer.DrawText(gac, "L", tableTextFont, new Point(rect.Left + 20, rect.Bottom - 20), bzColor); } bzColor = newColor; if (!_schedule.GetCriteria()[ci + 3]) { bzColor = errorColor; TextRenderer.DrawText(gac, "P", tableTextFont, new Point(rect.Left + 30, rect.Bottom -20), bzColor); } else { TextRenderer.DrawText(gac, "P", tableTextFont, new Point(rect.Left + 30, rect.Bottom -20), bzColor); } bzColor = newColor; if (!_schedule.GetCriteria()[ci + 4]) { bzColor = errorColor; TextRenderer.DrawText(gac, "G", tableTextFont, new Point(rect.Left + 40, rect.Bottom - 20), bzColor); } else { TextRenderer.DrawText(gac, "G", tableTextFont, new Point(rect.Left + 40, rect.Bottom - 20), bzColor); } } } }
執行後,遺傳演算法多次迭代後,顯示的UI介面如下:
中間環節,還不能得到可行解的迭代過程,可能顯示如下的介面:
由於週一的【8-10】和【9-11】有兩個課程同時佔用了同一個教室,因此,UI上會顯示紅色的斜紋,同時R(Room)為紅色。至此,我們基本實現了一個用C#語言實現的遺傳演算法,來進行簡單的課程排程操作。最後,本部落格參考 https://www.codeproject.com/articles/23111/making-a-class-schedule-using-a-genetic-algorithm 一文。