MBPSO_Team_Formation 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/lib/MBPSO_Team_Formation.rb +9 -0
- data/lib/MBPSO_Team_Formation/mbpso.rb +401 -0
- data/lib/MBPSO_Team_Formation/mvh.rb +121 -0
- data/lib/MBPSO_Team_Formation/neighbourhood.rb +132 -0
- data/lib/MBPSO_Team_Formation/particle.rb +317 -0
- data/lib/MBPSO_Team_Formation/validation.rb +163 -0
- data/lib/MBPSO_Team_Formation/version.rb +3 -0
- metadata +52 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6b95dd736a6a8ee8c4af23f99ec498287a3fe7fbcf562c958d68ad5e73e3e2cb
|
|
4
|
+
data.tar.gz: 6c4006ed7edb1b9979d4eaa2a46aa1b374386c6a6c3ddf75e5c1fabb7a136179
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 807a0817d4a3cbb3af15251462f8f757f02c5734c859e064678e39f3686c358296238c045b5436ef889ef52cb5cb3ae8590ae5bba95946c4f3dd8b1a20e5a491
|
|
7
|
+
data.tar.gz: 4466bd9d512f0ba43eec8dc86d081a9cf18b793aace5d274f2270a13686b0c861b9c1af26e4fe8eb77b5c5ce8cb89828c1be29839e644f7097804c2fae7c693f
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'neighbourhood'
|
|
4
|
+
require_relative 'validation'
|
|
5
|
+
require_relative 'mvh'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
|
|
8
|
+
module MBPSOTeamFormation
|
|
9
|
+
# Containing the needed functionality for validating the input data, instantiating all needed objects and running tha algorithm
|
|
10
|
+
class MBPSO
|
|
11
|
+
|
|
12
|
+
def initialize(table, team_size: 4, max_iterations: 10000, num_particles: 20, \
|
|
13
|
+
gender_weight: 9, ethnicity_weight: 9, initial_inertia: 1.0, final_inertia: 0.2, \
|
|
14
|
+
control_param_personal: [0.2, 0.4, 0.55], control_param_local: [0.6, 0.2, 0.9], \
|
|
15
|
+
survival_number: nil, final_survival_number: 8, \
|
|
16
|
+
skill_table: {0..39 => 1, 40..49 => 2, 50..59 => 3, 60..69 => 4, 70..79 => 5, 80..100 => 6}, \
|
|
17
|
+
forbidden_pairs: nil, tolerate_missing_values: true, init_num_particles: 3, \
|
|
18
|
+
output_stats: false, output_stats_name: 'stats.csv', neigh_change_interval: 100, inertia_change_interval: 10, \
|
|
19
|
+
sn_change_interval: 10, particles_to_move: 2, inertia_changes: 300, sn_changes: 300, convergence_iterations: 300)
|
|
20
|
+
|
|
21
|
+
validation = Validation.new
|
|
22
|
+
mvh = MVH.new
|
|
23
|
+
|
|
24
|
+
@table = validation.validate_dataset(table).dup
|
|
25
|
+
@length = table.length # Extracting number of students
|
|
26
|
+
|
|
27
|
+
@teams_size = validation\
|
|
28
|
+
.validate_number(team_size, 'teams', 'pos_int')
|
|
29
|
+
# Validating inputs
|
|
30
|
+
@teams = (@length / @teams_size).to_i
|
|
31
|
+
@max_iterations = validation\
|
|
32
|
+
.validate_number(max_iterations, 'max_iterations', 'pos_int')
|
|
33
|
+
@num_particles = validation\
|
|
34
|
+
.validate_number(num_particles, 'num_particles', 'pos_int')
|
|
35
|
+
@neigh_change_interval = validation\
|
|
36
|
+
.validate_number(neigh_change_interval, 'neigh_change_interval', 'pos_int')
|
|
37
|
+
@init_num_particles = validation\
|
|
38
|
+
.validate_number(init_num_particles, 'init_num_particles', 'pos_int')
|
|
39
|
+
@inertia_change_interval = validation\
|
|
40
|
+
.validate_number(inertia_change_interval, 'inertia_change_interval', 'pos_int')
|
|
41
|
+
@sn_change_interval = validation\
|
|
42
|
+
.validate_number(sn_change_interval, 'sn_change_interval', 'pos_int')
|
|
43
|
+
@particles_to_move = validation\
|
|
44
|
+
.validate_number(particles_to_move, 'particles_to_move', 'pos_int')
|
|
45
|
+
@final_survival_number = validation\
|
|
46
|
+
.validate_number(final_survival_number, 'final_survival_number', 'pos_int')
|
|
47
|
+
@inertia_changes = validation\
|
|
48
|
+
.validate_number(inertia_changes, 'inertia_changes', 'pos_int')
|
|
49
|
+
@sn_changes = validation\
|
|
50
|
+
.validate_number(sn_changes, 'sn_changes', 'pos_int')
|
|
51
|
+
@convergence_iterations = validation\
|
|
52
|
+
.validate_number(convergence_iterations, 'convergence_iterations', 'pos_int')
|
|
53
|
+
@gender_weight = validation\
|
|
54
|
+
.validate_number(gender_weight, 'gender_weight', 'nn_num')
|
|
55
|
+
@ethnicity_weight = validation\
|
|
56
|
+
.validate_number(ethnicity_weight, 'ethnicity_weight', 'nn_num')
|
|
57
|
+
@initial_inertia = validation\
|
|
58
|
+
.validate_number(initial_inertia, 'initial_inertia', 'nn_num')
|
|
59
|
+
@final_inertia = validation\
|
|
60
|
+
.validate_number(final_inertia, 'final_inertia', 'nn_num')
|
|
61
|
+
@control_param_personal = validation\
|
|
62
|
+
.validate_control_parameters(control_param_personal, 'personal')
|
|
63
|
+
@control_param_local = validation\
|
|
64
|
+
.validate_control_parameters(control_param_local, 'local')
|
|
65
|
+
if survival_number.nil?
|
|
66
|
+
@survival_number = @length.to_f
|
|
67
|
+
else
|
|
68
|
+
@survival_number = validation\
|
|
69
|
+
.validate_survival_number(survival_number, @length).to_f
|
|
70
|
+
end
|
|
71
|
+
unless forbidden_pairs.nil?
|
|
72
|
+
@forbidden_pairs = validation\
|
|
73
|
+
.validate_forbidden_pairs(forbidden_pairs).dup
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@output_stats_name = output_stats_name.to_s
|
|
77
|
+
@skill_table = validation\
|
|
78
|
+
.validate_skill_table(skill_table)
|
|
79
|
+
@output_stats = validation\
|
|
80
|
+
.validate_bool(output_stats, 'output_stats')
|
|
81
|
+
medians = mvh\
|
|
82
|
+
.fill_missing_values(table, \
|
|
83
|
+
validation.validate_bool(tolerate_missing_values, 'tolerate_missing_values'))
|
|
84
|
+
|
|
85
|
+
# Variable that will hold the extra students, in case the class size
|
|
86
|
+
# is not a multiple of the team size
|
|
87
|
+
@separated = nil
|
|
88
|
+
separate_students(medians)
|
|
89
|
+
map_grades
|
|
90
|
+
|
|
91
|
+
unless forbidden_pairs.nil?
|
|
92
|
+
@forbidden_pairs = {}
|
|
93
|
+
hash_forbidden_pairs(validation\
|
|
94
|
+
.validate_forbidden_pairs(forbidden_pairs))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Calculating neighbourhood count and creating neighbours
|
|
99
|
+
@num_neighbourhoods = (@num_particles / @init_num_particles).to_i
|
|
100
|
+
# Adding additional neighbourhood when particles cannot be separated
|
|
101
|
+
# into neighbourhoods of equal size
|
|
102
|
+
if (@num_particles % @init_num_particles).positive?
|
|
103
|
+
@num_neighbourhoods += 1
|
|
104
|
+
end
|
|
105
|
+
@neighbourhoods_list = Array.new(@num_neighbourhoods)
|
|
106
|
+
initialise_neighbourhoods
|
|
107
|
+
|
|
108
|
+
# Calculating by how much inertia and survival number
|
|
109
|
+
# will be changed at each of their updates
|
|
110
|
+
@inertia_step = ((@initial_inertia - @final_inertia) / @inertia_changes).abs
|
|
111
|
+
@sn_step = ((@survival_number - @final_survival_number) / @sn_changes).abs
|
|
112
|
+
|
|
113
|
+
# For move_particles method, so we dont always start adding
|
|
114
|
+
# to the first neighbourhood, in case of unequal size neighbourhoods
|
|
115
|
+
@iter = 0
|
|
116
|
+
|
|
117
|
+
# Variables needed for outputting the values during tests
|
|
118
|
+
@average_global_bests = []
|
|
119
|
+
@global_bests = []
|
|
120
|
+
|
|
121
|
+
# Current iteration indicator
|
|
122
|
+
@iteration = 0
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Separating the needed number of students(between 1 and 3), which will be
|
|
126
|
+
# added to teams at the last stage, by looking for students matching the
|
|
127
|
+
# median values of each attributes
|
|
128
|
+
def separate_students(medians)
|
|
129
|
+
|
|
130
|
+
most_frequent_gender = medians[0]
|
|
131
|
+
most_frequent_ethnicity = medians[1]
|
|
132
|
+
mean = medians[2]
|
|
133
|
+
stdev = medians[3]
|
|
134
|
+
# Checking if the step is needed, terminating otherwise,
|
|
135
|
+
# also holding the number of students that need to be separated
|
|
136
|
+
remainder = @length % @teams_size
|
|
137
|
+
return true if remainder.zero?
|
|
138
|
+
|
|
139
|
+
@separated = CSV::Table.new([], headers: %w[id Gender Ethnicity Grade])
|
|
140
|
+
# Searching for students with attributes matching the most frequent
|
|
141
|
+
# non numeric values and mean +- the standard deviation grades
|
|
142
|
+
(0..@length - 1).each do |x|
|
|
143
|
+
# Safety preacution because of the @length update after
|
|
144
|
+
# the number of iterations is calculated
|
|
145
|
+
break if x == @length
|
|
146
|
+
next unless @table[x]['Gender'] == most_frequent_gender
|
|
147
|
+
|
|
148
|
+
next unless @table[x]['Ethnicity'] == most_frequent_ethnicity
|
|
149
|
+
|
|
150
|
+
next unless (@table[x]['Grade'].to_f < mean + stdev) || (x['Grade'].to_f > mean - stdev)
|
|
151
|
+
|
|
152
|
+
@separated << @table.delete(x)
|
|
153
|
+
|
|
154
|
+
remainder -= 1
|
|
155
|
+
@length -= 1
|
|
156
|
+
# iterating until no more students are needed to be separated
|
|
157
|
+
return true if remainder.zero?
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# If not enough students are found
|
|
161
|
+
# looks for students only having average grade
|
|
162
|
+
(0..@length-1).each do |x|
|
|
163
|
+
break if x == @length
|
|
164
|
+
next unless (@table[x]['Grade'].to_f < mean + stdev) || (x['Grade'].to_f > mean - stdev)
|
|
165
|
+
@separated << @table.delete(x)
|
|
166
|
+
remainder -= 1
|
|
167
|
+
@length -= 1
|
|
168
|
+
return true if remainder.zero?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# If it fails again. randomly separates students
|
|
172
|
+
(0..@length - 1).each do |x|
|
|
173
|
+
break if x == @length
|
|
174
|
+
y = rand(@table.length - 1)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@separated << @table.delete(y)
|
|
178
|
+
remainder -= 1
|
|
179
|
+
@length -= 1
|
|
180
|
+
return true if remainder.zero?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Replacing the grade entries in the data set variable with skill values
|
|
186
|
+
# according to the skill mapping Hash
|
|
187
|
+
def map_grades
|
|
188
|
+
(0..@length - 1).each do |x|
|
|
189
|
+
@table[x]['Grade'] = @skill_table\
|
|
190
|
+
.find { |r, _v| r.cover?(@table[x]['Grade'].to_i) }[1]
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Reworking the forbidden pairs list, by making it a Hash where for every
|
|
195
|
+
# students participating in at least one pair, there will be a key with
|
|
196
|
+
# its ID and a corresponding list of IDs of students which this student
|
|
197
|
+
# cannot be teamed up with
|
|
198
|
+
def hash_forbidden_pairs(list)
|
|
199
|
+
# Adding the reversed pairs of students to the list
|
|
200
|
+
(0..list.length - 1).each do |x|
|
|
201
|
+
list.push([list[x][1], list[x][0]])
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Making all unique first elements of the pairs in the list keys of the Hash
|
|
205
|
+
keys = list.map(&:first).uniq
|
|
206
|
+
# Adding the corresponding values, specified by the second elements in the pairs
|
|
207
|
+
@forbidden_pairs = keys.map do |k|
|
|
208
|
+
{k => list.select { |a| a[0] == k }.compact.map(&:last)}
|
|
209
|
+
end
|
|
210
|
+
@forbidden_pairs = @forbidden_pairs.reduce({}, :merge)
|
|
211
|
+
# Removing unique values for each key
|
|
212
|
+
@forbidden_pairs.keys.each do |x|
|
|
213
|
+
@forbidden_pairs[x] = @forbidden_pairs[x].uniq
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Initialising the needed number of initial neighbourhoods according to
|
|
218
|
+
# the user-specified/default parameters
|
|
219
|
+
def initialise_neighbourhoods
|
|
220
|
+
|
|
221
|
+
# With the implementation below, there is
|
|
222
|
+
# a danger of division by 0 exception
|
|
223
|
+
# if a single neighbourhood needs to be formed
|
|
224
|
+
if @num_neighbourhoods == 1
|
|
225
|
+
@neighbourhoods_list[0] = Neighbourhood\
|
|
226
|
+
.new(@length, @teams, @control_param_personal, @control_param_local, \
|
|
227
|
+
@initial_inertia, @table, @ethnicity_weight, @gender_weight, \
|
|
228
|
+
@init_num_particles, @forbidden_pairs, @survival_number)
|
|
229
|
+
return true
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Initialising all full capacity neighbourhoods
|
|
233
|
+
(0..@num_neighbourhoods - 2).each do |x|
|
|
234
|
+
@neighbourhoods_list[x] = Neighbourhood\
|
|
235
|
+
.new(@length, @teams, @control_param_personal, @control_param_local, \
|
|
236
|
+
@initial_inertia, @table, @ethnicity_weight, @gender_weight, \
|
|
237
|
+
@init_num_particles, @forbidden_pairs, @survival_number)
|
|
238
|
+
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Initialising the last neighbourhood which will hold a number
|
|
242
|
+
# of particles equal to the remained of the division of
|
|
243
|
+
# the particles number by the initial number of
|
|
244
|
+
# particles in each neighbourhood
|
|
245
|
+
@neighbourhoods_list[@num_neighbourhoods - 1] = Neighbourhood\
|
|
246
|
+
.new(@length, @teams, \
|
|
247
|
+
@control_param_personal, @control_param_local, @initial_inertia, @table, \
|
|
248
|
+
@ethnicity_weight, @gender_weight, (@num_particles % (@num_neighbourhoods - 1)), \
|
|
249
|
+
@forbidden_pairs, @survival_number)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Moving the particles from the last neighbourhood
|
|
253
|
+
# towards the other neighbourhoods
|
|
254
|
+
def move_particles
|
|
255
|
+
moved_particles = 0
|
|
256
|
+
num_to_move = 2 #@neighbourhoods_list.last.particles_list.size
|
|
257
|
+
@neighbourhoods_list[0].counter = 0
|
|
258
|
+
|
|
259
|
+
while moved_particles != num_to_move && @neighbourhoods_list.length > 1
|
|
260
|
+
temp = @neighbourhoods_list.last.remove_particle
|
|
261
|
+
|
|
262
|
+
# Checking if a particle was removed, or the neighbourhood is empty
|
|
263
|
+
if temp.nil?
|
|
264
|
+
@neighbourhoods_list.pop
|
|
265
|
+
else
|
|
266
|
+
@neighbourhoods_list[@iter].add_particle(temp)
|
|
267
|
+
|
|
268
|
+
# increase the counter indicating to which neighbourhood
|
|
269
|
+
# the next particle will be added, so they're
|
|
270
|
+
# added to different neighbourhoods on a roulette principle
|
|
271
|
+
@iter += 1
|
|
272
|
+
moved_particles += 1
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Start over if a particle was added to each neighbourhood
|
|
276
|
+
@iter = 0 if @iter == @neighbourhoods_list.length - 1
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Update inertia and topology, at the needed iterations
|
|
281
|
+
def update_characteristics
|
|
282
|
+
# Invoking method for topology update if needed
|
|
283
|
+
if (@iteration % @neigh_change_interval).zero? && @neighbourhoods_list.length > 1
|
|
284
|
+
move_particles
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
if (@iteration % @sn_change_interval).zero? && (@survival_number > @final_survival_number)
|
|
288
|
+
@survival_number -= @sn_step
|
|
289
|
+
@neighbourhoods_list.each do |x|
|
|
290
|
+
x.update_sn(@survival_number.to_i)
|
|
291
|
+
end
|
|
292
|
+
@neighbourhoods_list[0].counter = 0
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Checking if inertia should be updated
|
|
296
|
+
return unless (@iteration % @inertia_change_interval).zero? && (@initial_inertia - @inertia_step) > @final_inertia
|
|
297
|
+
|
|
298
|
+
# Resetting converge check counter
|
|
299
|
+
@neighbourhoods_list[0].counter = 0
|
|
300
|
+
|
|
301
|
+
# Calculating new inertia with the precaution of not going past the final value
|
|
302
|
+
|
|
303
|
+
@initial_inertia -= @inertia_step
|
|
304
|
+
# Asking each neighbourhoods to update the inertia weights
|
|
305
|
+
# of all the particles that belong to it
|
|
306
|
+
@neighbourhoods_list.each do |x|
|
|
307
|
+
x.update_inertia(@initial_inertia)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Writing the statistics about the algorithm run to an
|
|
313
|
+
# external .csv file
|
|
314
|
+
def export_data
|
|
315
|
+
folder = "\data"
|
|
316
|
+
FileUtils.mkdir_p folder
|
|
317
|
+
CSV.open(File.join(folder, @output_stats_name), 'wb') do |csv|
|
|
318
|
+
csv << @global_bests
|
|
319
|
+
csv << @average_global_bests
|
|
320
|
+
@neighbourhoods_list[0].report_particles.each do |x|
|
|
321
|
+
csv << x
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Assigning the separated at the beginning students to random teams
|
|
327
|
+
# If there were any separated students
|
|
328
|
+
def assign_separated(result)
|
|
329
|
+
prev_rand = 0
|
|
330
|
+
(0..@separated.length-1).each do |x|
|
|
331
|
+
curr_rand = rand(@teams)
|
|
332
|
+
|
|
333
|
+
# Making sure that no more than one of the separated students
|
|
334
|
+
# is added to a given team, regardless of the probability of that happening
|
|
335
|
+
curr_rand = rand(@teams) while curr_rand == prev_rand
|
|
336
|
+
result[curr_rand].append(@separated[x]['id'])
|
|
337
|
+
prev_rand = curr_rand
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Formatiing and returning the output
|
|
342
|
+
#
|
|
343
|
+
# @return [Array] Team allocation in the form of list of lists
|
|
344
|
+
# containing the IDs of the students allocated to each team
|
|
345
|
+
def return_teams
|
|
346
|
+
result = Array.new(@teams) { [] }
|
|
347
|
+
allocation = @neighbourhoods_list[0].l_best_position
|
|
348
|
+
(0..@length - 1).each do |x|
|
|
349
|
+
(0..@teams - 1).each do |y|
|
|
350
|
+
result[y].append(@table[x]['id']) if allocation[x][y] == 1
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
# If there are any separated students
|
|
354
|
+
# Assign them to teams
|
|
355
|
+
assign_separated(result) unless @separated.nil?
|
|
356
|
+
result
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Starting the algorithm
|
|
360
|
+
def run
|
|
361
|
+
while @max_iterations > @iteration
|
|
362
|
+
# Array that will contain the local bests for the current iteration
|
|
363
|
+
temp = []
|
|
364
|
+
|
|
365
|
+
# Get every neighbourhood to iterate all its particles
|
|
366
|
+
@neighbourhoods_list.each do |x|
|
|
367
|
+
x.iterate_particles
|
|
368
|
+
temp.push(x.l_best_fitness)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Add the global bests and the average local best fitness
|
|
372
|
+
# to the values storing them
|
|
373
|
+
@global_bests << temp.max
|
|
374
|
+
@average_global_bests << (temp.sum / temp.length)
|
|
375
|
+
@iteration += 1
|
|
376
|
+
|
|
377
|
+
# Check if the first neighbourhood is signalling for
|
|
378
|
+
# a long period with no improvements in the local best fitness
|
|
379
|
+
if @neighbourhoods_list[0].counter > @convergence_iterations
|
|
380
|
+
@iteration = @max_iterations
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Invoke the method that will check if the control
|
|
384
|
+
# parameters need to be updated and will act accordingly
|
|
385
|
+
update_characteristics
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Check if exporting the run statistics are desired by the user
|
|
389
|
+
# and export if needed
|
|
390
|
+
export_data if @output_stats
|
|
391
|
+
|
|
392
|
+
# Printing the attributes of the best allcoation
|
|
393
|
+
@neighbourhoods_list[0].print_best
|
|
394
|
+
|
|
395
|
+
# Return the proposed allocation
|
|
396
|
+
return_teams
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
private :separate_students, :map_grades, :hash_forbidden_pairs, :initialise_neighbourhoods, :move_particles
|
|
400
|
+
end
|
|
401
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module MBPSOTeamFormation
|
|
3
|
+
# Missing Values Handler
|
|
4
|
+
class MVH
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Checking for missing values in the data set
|
|
8
|
+
def check_missing_values(table)
|
|
9
|
+
temp = Array.new(3) { [] } # Array that will hold the results, each array
|
|
10
|
+
# inside it holds the indexes for a particular attribute
|
|
11
|
+
|
|
12
|
+
# Checking Genders
|
|
13
|
+
(0..table['Gender'].length - 1).each do |x|
|
|
14
|
+
temp[0].append(x) if table['Gender'][x].nil?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Checking ethnicities
|
|
18
|
+
(0..table['Ethnicity'].length - 1).each do |x|
|
|
19
|
+
temp[1].append(x) if table['Ethnicity'][x].nil?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Checking grades
|
|
23
|
+
(0..table['Grade'].length - 1).each do |x|
|
|
24
|
+
temp[2].append(x) if table['Grade'][x].nil?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Checking results
|
|
28
|
+
if !temp[0].empty? || !temp[1].empty? || !temp[2].empty?
|
|
29
|
+
temp
|
|
30
|
+
else
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Calculating the mean and standard deviation of data,
|
|
36
|
+
# to be used when replacing missing grades
|
|
37
|
+
def calculate_stdev(data)
|
|
38
|
+
data = data.compact.map(&:to_i)
|
|
39
|
+
mean = data.sum.to_f / data.size
|
|
40
|
+
sum = 0
|
|
41
|
+
|
|
42
|
+
data.each { |v| sum += (v - mean) ** 2 }
|
|
43
|
+
stdev = Math.sqrt(sum / data.size)
|
|
44
|
+
|
|
45
|
+
[mean, stdev]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Replacing missing values by the most frequent values for non-numeric
|
|
49
|
+
# attributes and keeping original distribution when it comes to grades
|
|
50
|
+
def fill_missing_values(table, tolerate_missing_values)
|
|
51
|
+
# Running only in case of missing values
|
|
52
|
+
missing_values = check_missing_values(table)
|
|
53
|
+
|
|
54
|
+
most_frequent_gender, most_frequent_ethnicity, mean_grade, stdev = nil
|
|
55
|
+
|
|
56
|
+
mean_grade, stdev = calculate_stdev(table['Grade'])
|
|
57
|
+
|
|
58
|
+
frequencies = table['Gender']\
|
|
59
|
+
.each_with_object(Hash.new(0)) { |v, h| h[v] += 1; }
|
|
60
|
+
most_frequent_gender = table['Gender'].max_by { |v| frequencies[v] }
|
|
61
|
+
|
|
62
|
+
frequencies = table['Ethnicity']\
|
|
63
|
+
.each_with_object(Hash.new(0)) { |v, h| h[v] += 1; }
|
|
64
|
+
most_frequent_ethnicity = table['Ethnicity'].max_by { |v| frequencies[v] }
|
|
65
|
+
|
|
66
|
+
return [most_frequent_gender, most_frequent_ethnicity, mean_grade, stdev, true] unless missing_values
|
|
67
|
+
|
|
68
|
+
# Notifying the user for the missing values and proceeding
|
|
69
|
+
# according to the tolerance parameter
|
|
70
|
+
unless tolerate_missing_values
|
|
71
|
+
raise ArgumentError, 'Missing values are present in the data set'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
warn('WARNING! There are missing values in the data set,'\
|
|
75
|
+
' which will be automatically handled.')
|
|
76
|
+
|
|
77
|
+
# Replacing missing gender values with the most frequent gender in the data set
|
|
78
|
+
unless missing_values[0].empty?
|
|
79
|
+
missing_values[0].each do |x|
|
|
80
|
+
table[x]['Gender'] = most_frequent_gender
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Replacing missing ethnicity values with the most frequent gender in the data set
|
|
85
|
+
unless missing_values[1].empty?
|
|
86
|
+
missing_values[1].each do |x|
|
|
87
|
+
table[x]['Ethnicity'] = most_frequent_ethnicity
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Replacing missing grade values according to the mean and standard
|
|
92
|
+
# deviation of the data to keep the original distribution
|
|
93
|
+
unless missing_values[2].empty?
|
|
94
|
+
missing_values[2].each do |x|
|
|
95
|
+
case (rand * 100).round
|
|
96
|
+
when 0..1
|
|
97
|
+
table[x]['Grade'] = [(mean_grade - 3 * stdev).round, 0].max
|
|
98
|
+
when 2..9
|
|
99
|
+
table[x]['Grade'] = [(mean_grade - 2 * stdev).round, 0].max
|
|
100
|
+
when 10..33
|
|
101
|
+
table[x]['Grade'] = (mean_grade - stdev).round
|
|
102
|
+
when 34..66
|
|
103
|
+
table[x]['Grade'] = mean_grade.round
|
|
104
|
+
when 67..91
|
|
105
|
+
table[x]['Grade'] = (mean_grade + stdev).round
|
|
106
|
+
when 92..99
|
|
107
|
+
table[x]['Grade'] = [(mean_grade + 2 * stdev).round, 100].min
|
|
108
|
+
when 99..100
|
|
109
|
+
table[x]['Grade'] = [(mean_grade - 3 * stdev).round, 100].min
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
# Returning the already calculated most statistical parameters to be
|
|
114
|
+
# used for finding students with close to median attributes if necessary
|
|
115
|
+
# puts "mean - #{mean}, stdev - #{stdev}"
|
|
116
|
+
[most_frequent_gender, most_frequent_ethnicity, mean_grade, stdev]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private :check_missing_values, :calculate_stdev
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
require_relative 'particle'
|
|
2
|
+
module MBPSOTeamFormation
|
|
3
|
+
class Neighbourhood
|
|
4
|
+
|
|
5
|
+
attr_reader :l_best_position, :l_best_fitness, :terminate, :ret_value, :particles_list
|
|
6
|
+
attr_accessor :counter
|
|
7
|
+
|
|
8
|
+
def initialize(length, teams, control_param_personal, control_param_local, \
|
|
9
|
+
inertia, table, ethnicity_weight, gender_weight, \
|
|
10
|
+
init_num_particles, forbidden_pairs, survival_number)
|
|
11
|
+
@particles_list = []
|
|
12
|
+
|
|
13
|
+
@l_best_fitness = -90_000
|
|
14
|
+
@l_best_position = Array.new(length) { Array.new(teams, 0) }
|
|
15
|
+
|
|
16
|
+
@length = length
|
|
17
|
+
@teams = teams
|
|
18
|
+
@init_num_particles = init_num_particles
|
|
19
|
+
@table = table
|
|
20
|
+
|
|
21
|
+
# Initialising particles
|
|
22
|
+
initialise_particles(control_param_personal, control_param_local, inertia, \
|
|
23
|
+
table, ethnicity_weight, gender_weight, survival_number, forbidden_pairs)
|
|
24
|
+
|
|
25
|
+
# Number of iterations without local best update
|
|
26
|
+
@counter = 0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Initialising the needed number of particles by adding
|
|
30
|
+
# to the list of particles for the current neighbourhood object
|
|
31
|
+
def initialise_particles(cpp, cpl, init_in, table, ew, gw, sn, fp)
|
|
32
|
+
(0..@init_num_particles - 1).each do |_x|
|
|
33
|
+
@particles_list.push(Particle\
|
|
34
|
+
.new(@length, @teams, cpp, cpl, init_in, \
|
|
35
|
+
table, ew, gw, sn, fp))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Add particles to the neighbourhood
|
|
40
|
+
def add_particle(particle)
|
|
41
|
+
@particles_list.push(particle)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Remove particles from the neighbourhood
|
|
45
|
+
#
|
|
46
|
+
# @return [Particle, nil] Return the particle if successfully removed\
|
|
47
|
+
# or nil if the list of particles is empty
|
|
48
|
+
def remove_particle
|
|
49
|
+
@particles_list.pop
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Update the local best position and fitness if any of the particles
|
|
53
|
+
# has fitness higher than the current local best
|
|
54
|
+
def update_l_best
|
|
55
|
+
# Indicator of whether the fitness has been updated at the current iteration
|
|
56
|
+
@flag = false
|
|
57
|
+
|
|
58
|
+
(0..@particles_list.length - 1).each do |x|
|
|
59
|
+
next unless @particles_list[x].p_best_fitness > @l_best_fitness
|
|
60
|
+
|
|
61
|
+
@l_best_fitness = @particles_list[x].p_best_fitness
|
|
62
|
+
@l_best_position = @particles_list[x].p_best_position
|
|
63
|
+
@flag = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Checking if local best has been updated to maintain
|
|
67
|
+
# the counter of iterations with no improvement
|
|
68
|
+
if @flag
|
|
69
|
+
@counter = 0
|
|
70
|
+
else
|
|
71
|
+
@counter += 1
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def iterate_particles
|
|
76
|
+
@particles_list.each do |x|
|
|
77
|
+
x.update_velocity(@l_best_position)
|
|
78
|
+
x.update_position
|
|
79
|
+
x.calculate_fitness
|
|
80
|
+
x.update_stats
|
|
81
|
+
end
|
|
82
|
+
update_l_best
|
|
83
|
+
# puts "Global best: #{@l_best_fitness}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
#
|
|
87
|
+
def update_inertia(inertia)
|
|
88
|
+
@particles_list.each do |x|
|
|
89
|
+
x.inertia = inertia
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def update_sn(survival_number)
|
|
94
|
+
@particles_list.each do |x|
|
|
95
|
+
x.survival_number = survival_number
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def report_particles
|
|
101
|
+
array = Array.new(@particles_list.length)
|
|
102
|
+
(0..@particles_list.length - 1).each do |x|
|
|
103
|
+
array[x] = @particles_list[x].stats
|
|
104
|
+
end
|
|
105
|
+
array
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Prints the attributes of the resulted alocation
|
|
109
|
+
def print_best
|
|
110
|
+
gender = []
|
|
111
|
+
ethnicity = []
|
|
112
|
+
grade = []
|
|
113
|
+
|
|
114
|
+
(0..@teams - 1).each do |y|
|
|
115
|
+
(0..@length - 1).each do |x|
|
|
116
|
+
next unless @l_best_position[x][y] == 1
|
|
117
|
+
|
|
118
|
+
gender.push(@table[x]['Gender'])
|
|
119
|
+
ethnicity.push(@table[x]['Ethnicity'])
|
|
120
|
+
grade.push(@table[x]['Grade'].to_i)
|
|
121
|
+
end
|
|
122
|
+
puts " Team#{y}'s attributes arrays:\nGender: #{gender} \nEthnicity: #{ethnicity} \nGrade:#{grade}"
|
|
123
|
+
gender.clear
|
|
124
|
+
ethnicity.clear
|
|
125
|
+
grade.clear
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
private :update_l_best
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
module MBPSOTeamFormation
|
|
2
|
+
class Particle
|
|
3
|
+
attr_reader :p_best_fitness, :p_best_position, :stats, :fitness
|
|
4
|
+
attr_accessor :position, :inertia, :survival_number
|
|
5
|
+
|
|
6
|
+
def initialize(length, teams, \
|
|
7
|
+
control_param_personal, control_param_local, inertia, \
|
|
8
|
+
table, ethnicity_weight, gender_weight, \
|
|
9
|
+
survival_number, forbidden_pairs)
|
|
10
|
+
@table = table
|
|
11
|
+
|
|
12
|
+
@length = length
|
|
13
|
+
@teams = teams
|
|
14
|
+
|
|
15
|
+
@position = Array.new(length) { Array.new(teams, 0) }
|
|
16
|
+
@velocity = Array.new(length) { Array.new(teams, 0) }
|
|
17
|
+
@new_velocity = Array.new(length) { Array.new(teams, 0) }
|
|
18
|
+
|
|
19
|
+
initial_particle_assignment
|
|
20
|
+
|
|
21
|
+
@inertia = inertia
|
|
22
|
+
@control_param_personal = control_param_personal
|
|
23
|
+
@control_param_local = control_param_local
|
|
24
|
+
@ethnicity_weight = ethnicity_weight
|
|
25
|
+
@gender_weight = gender_weight
|
|
26
|
+
|
|
27
|
+
@fitness = 0
|
|
28
|
+
@p_best_fitness = -900_000
|
|
29
|
+
@p_best_position = Array.new(length) { Array.new(teams, 0) }
|
|
30
|
+
# Array holding the particle fitness along
|
|
31
|
+
# the run of the algorithm, used for testing purposes
|
|
32
|
+
@stats = []
|
|
33
|
+
|
|
34
|
+
# Number of final swapping suggestions
|
|
35
|
+
# to be considered when updating position
|
|
36
|
+
@survival_number = survival_number
|
|
37
|
+
|
|
38
|
+
# Probability threshold above which only @survival_number of values are left
|
|
39
|
+
@threshold = 0
|
|
40
|
+
@forbidden_pairs = forbidden_pairs
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Generating the initial position of the particle
|
|
44
|
+
# by assigning each to student to a random team
|
|
45
|
+
def initial_particle_assignment
|
|
46
|
+
array = 0.upto(@length - 1).to_a
|
|
47
|
+
array = array.shuffle
|
|
48
|
+
(0..@length - 1).each do |x|
|
|
49
|
+
student = array[x]
|
|
50
|
+
@position[student][x % @teams] = 1
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Calculate the fitness of the solution the particle represents
|
|
55
|
+
def calculate_fitness
|
|
56
|
+
total_fitness = 0
|
|
57
|
+
|
|
58
|
+
# Arrays to hold the attribute values for each team
|
|
59
|
+
gender = []
|
|
60
|
+
ethnicity = []
|
|
61
|
+
grade = []
|
|
62
|
+
id = []
|
|
63
|
+
|
|
64
|
+
(0..@teams - 1).each do |y| # Iterating through all teams
|
|
65
|
+
(0..@length - 1).each do |x| # Iterating through all students
|
|
66
|
+
next unless @position[x][y] == 1
|
|
67
|
+
|
|
68
|
+
# Only checking for forbidden team formations
|
|
69
|
+
# if there are any forbidden pairs at all
|
|
70
|
+
unless @forbidden_pairs.nil?
|
|
71
|
+
temp = [] # List with students that are forbidden to join the team
|
|
72
|
+
|
|
73
|
+
# Checking if the particular student is already in
|
|
74
|
+
# the list of forbidden students for the particular team
|
|
75
|
+
|
|
76
|
+
if temp.include? @table[x]['id'] # If this student cannot be assigned to this team
|
|
77
|
+
initial_particle_assignment # Change the current postion with a random one
|
|
78
|
+
calculate_fitness #and calculate its new fitness
|
|
79
|
+
return false # terminate the method
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Adding all forbidden mates of the student to the list with forbidden teammates
|
|
83
|
+
if @forbidden_pairs.key?(@table[x]['id'])
|
|
84
|
+
temp.append(@forbidden_pairs[@table[x]['id']])
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Extract attributes of students in the team
|
|
89
|
+
# into the temporary arrays so the distances can be computed
|
|
90
|
+
gender.push(@table[x]['Gender'])
|
|
91
|
+
ethnicity.push(@table[x]['Ethnicity'])
|
|
92
|
+
grade.push(@table[x]['Grade'].to_i)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Calculate the distances between students
|
|
96
|
+
(0..grade.length - 2).each do |i|
|
|
97
|
+
(i + 1..grade.length - 1).each do |index|
|
|
98
|
+
dist = 0 # sum of distances
|
|
99
|
+
# As this is non-numeric attribute represented however
|
|
100
|
+
# by a numeric value, we're interested only if
|
|
101
|
+
# they are different, not by how much as it is irrelevant
|
|
102
|
+
dist += @gender_weight unless gender[i] == gender[index]
|
|
103
|
+
dist += @ethnicity_weight unless ethnicity[i] == ethnicity[index]
|
|
104
|
+
dist += (grade[i] - grade[index])**2
|
|
105
|
+
|
|
106
|
+
# Adding the distances between students for
|
|
107
|
+
# the current team to the total fitness
|
|
108
|
+
dist.positive? ? total_fitness += Math.sqrt(dist) : total_fitness -= Math.sqrt(dist.abs)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
case grade.uniq.length
|
|
112
|
+
when 1
|
|
113
|
+
total_fitness -= 80
|
|
114
|
+
when 2
|
|
115
|
+
total_fitness -= 150
|
|
116
|
+
when 1
|
|
117
|
+
total_fitness -= 300
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
gender.clear
|
|
121
|
+
ethnicity.clear
|
|
122
|
+
grade.clear
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@fitness = total_fitness
|
|
126
|
+
# Check if the current fitness is better than the personal best one
|
|
127
|
+
update_p_best
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Comparing current and local best fitness and updating accordingly
|
|
131
|
+
def update_p_best
|
|
132
|
+
return unless @fitness > p_best_fitness
|
|
133
|
+
|
|
134
|
+
@p_best_fitness = @fitness
|
|
135
|
+
@p_best_position = @position
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Generating the random components for updating velocities
|
|
139
|
+
# The passing of parameter makes the method reusable for both
|
|
140
|
+
# personal and local random factors
|
|
141
|
+
#
|
|
142
|
+
# @param [Array] param Control parameter according to
|
|
143
|
+
# which the random component will be generated
|
|
144
|
+
# @return [Array] The resulting random component
|
|
145
|
+
def generate_random_vector(param)
|
|
146
|
+
random_vector = Array.new(@length) { Array.new(@teams) { rand } } # Generate matrix of random values
|
|
147
|
+
random_vector.each do |x|
|
|
148
|
+
x.each do |y|
|
|
149
|
+
# If the value is higher than the
|
|
150
|
+
# threshold, put the specified probability there
|
|
151
|
+
y = y > param[0] ? param[1] : 0
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
random_vector
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Generate swapping suggestions for velocity updates by applying
|
|
158
|
+
# logical XOR operator to the corresponding positions in
|
|
159
|
+
# the current position and personal/local best position matrices
|
|
160
|
+
# @param [Array] minuend The position to be compared with
|
|
161
|
+
# the current position - personal or local best
|
|
162
|
+
# @param [float] param Parameter according to which probabilities
|
|
163
|
+
# will be updates on the places where swapping suggestions are found
|
|
164
|
+
# @return [Array] The resulting probability matrix
|
|
165
|
+
def subtract_position(minuend, param)
|
|
166
|
+
result = Array.new(@length) { Array.new(@teams, 0) }
|
|
167
|
+
|
|
168
|
+
# Iterate through the matrices and update the result matrix
|
|
169
|
+
# according to the XOR operation output and specified parameter
|
|
170
|
+
(0..@length - 1).each do |x|
|
|
171
|
+
(0..@teams - 1).each do |y|
|
|
172
|
+
result[x][y] = (@position[x][y] != minuend[x][y]) ? param : 0
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
result
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Calculating the positional sums of the passed probability matrices
|
|
179
|
+
# @param [Array] args Array of the probability matrices that are to be summed
|
|
180
|
+
# @return [Array] Resulting probability matrix
|
|
181
|
+
def sum_probability_matrices(*args)
|
|
182
|
+
result = Array.new(@length) { Array.new(@teams, 0) }
|
|
183
|
+
(0..@length - 1).each do |x|
|
|
184
|
+
(0..@teams - 1).each do |y|
|
|
185
|
+
(0..args.size - 1).each do |z|
|
|
186
|
+
result[x][y] += args[z][x][y]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
result
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Calculate and update particle's velocity
|
|
194
|
+
#
|
|
195
|
+
# @param [Array] l_best_position The neighbourhood's local best position.
|
|
196
|
+
# It is passed to the method when called by the neighbourhood object
|
|
197
|
+
# to avoid storing it for each particle, as well to conserve
|
|
198
|
+
# the one way relationship between particle and neighbourhood
|
|
199
|
+
# @return [Array] The resulting velocity
|
|
200
|
+
def update_velocity(l_best_position)
|
|
201
|
+
# Generating the second and third parameters in the velocity update equation
|
|
202
|
+
term2 = sum_probability_matrices(generate_random_vector(@control_param_personal), subtract_position(@p_best_position, @control_param_personal[2]))
|
|
203
|
+
term3 = sum_probability_matrices(generate_random_vector(@control_param_local), subtract_position(l_best_position, @control_param_local[2]))
|
|
204
|
+
new_velocity = Array.new(@length) { Array.new(@teams, 0) }
|
|
205
|
+
|
|
206
|
+
# Summing the current velocity with the weighted parameters
|
|
207
|
+
(0..@length - 1).each do |x|
|
|
208
|
+
(0..@teams - 1).each do |y|
|
|
209
|
+
new_velocity[x][y] += (@velocity[x][y] * @inertia)
|
|
210
|
+
new_velocity[x][y] += term2[x][y]
|
|
211
|
+
new_velocity[x][y] += term3[x][y]
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Normalising the velocity as a fraction
|
|
216
|
+
# of the maximum value present in the matrix
|
|
217
|
+
max_probability = new_velocity.flatten.max
|
|
218
|
+
(0..@length - 1).each do |x|
|
|
219
|
+
(0..@teams - 1).each do |y|
|
|
220
|
+
new_velocity[x][y] = new_velocity[x][y] / max_probability
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
@velocity = new_velocity
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Updating particle's position
|
|
228
|
+
def update_position
|
|
229
|
+
# Array holding the free slots in each team
|
|
230
|
+
free_slots = Array.new(@teams, (@length / @teams).to_i)
|
|
231
|
+
unassigned_students = []
|
|
232
|
+
new_position = Array.new(@length) { Array.new(@teams, 0) }
|
|
233
|
+
randomised_current_position = Array.new(@length) { Array.new(@teams, 0) }
|
|
234
|
+
|
|
235
|
+
# Sum up current position and velocity
|
|
236
|
+
@velocity = sum_probability_matrices(randomised_current_position, @velocity)
|
|
237
|
+
|
|
238
|
+
# Calculate the survivability threshold
|
|
239
|
+
@threshold = @velocity.flatten.max(@survival_number.to_i + 1).last
|
|
240
|
+
|
|
241
|
+
(0..@length - 1).each do |x|
|
|
242
|
+
# First assign students where velocity doesnt
|
|
243
|
+
# suggest changes to avoid extra swaps
|
|
244
|
+
if @velocity[x].flatten.max < @threshold
|
|
245
|
+
(0..@teams - 1).each do |y|
|
|
246
|
+
new_position[x][y] = @position[x][y]
|
|
247
|
+
free_slots[y] -= 1 if new_position[x][y] == 1
|
|
248
|
+
end
|
|
249
|
+
else
|
|
250
|
+
unassigned_students.push(x)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
probabilities_indices = Array.new { Array.new }
|
|
255
|
+
unassigned_students2 = []
|
|
256
|
+
unassigned_students.each do |x|
|
|
257
|
+
# List of indexes in order that when referenced relates to a sorted list
|
|
258
|
+
probabilities_indices[x] = @velocity[x].map.with_index.sort.map(&:last)
|
|
259
|
+
if free_slots[probabilities_indices[x][0]].positive?
|
|
260
|
+
new_position[x][probabilities_indices[x][0]] = 1
|
|
261
|
+
free_slots[probabilities_indices[x][0]] -= 1
|
|
262
|
+
else
|
|
263
|
+
unassigned_students2.push(x)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
# Implemented like that because in order to generate the indexes
|
|
267
|
+
# the whole table has to be iterated once anyway
|
|
268
|
+
# and then continue, therefore some needed actions are squeezed in
|
|
269
|
+
|
|
270
|
+
index = 1 # Representing the index of the sorted probabilities
|
|
271
|
+
temp = unassigned_students2
|
|
272
|
+
while free_slots.sum.positive? && index < @teams
|
|
273
|
+
unassigned_students2 = temp.dup
|
|
274
|
+
temp.clear
|
|
275
|
+
if index > 4
|
|
276
|
+
until unassigned_students2[0].nil?
|
|
277
|
+
(0..@teams - 1).each do |x|
|
|
278
|
+
next unless free_slots[x].positive?
|
|
279
|
+
|
|
280
|
+
new_position[unassigned_students2[0]][x] = 1
|
|
281
|
+
free_slots[x] -= 1
|
|
282
|
+
unassigned_students2.shift
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
unassigned_students2.each do |x|
|
|
287
|
+
if free_slots[probabilities_indices[x][index]].positive?
|
|
288
|
+
new_position[x][probabilities_indices[x][index]] = 1
|
|
289
|
+
free_slots[probabilities_indices[x][index]] -= 1
|
|
290
|
+
else
|
|
291
|
+
temp.push(x)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
index += 1
|
|
295
|
+
end
|
|
296
|
+
@position = new_position
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Neatly printing a matrix
|
|
300
|
+
# @param [Array] array Matrix to be printed
|
|
301
|
+
def print(array)
|
|
302
|
+
arr = array.transpose
|
|
303
|
+
width = arr.flatten.max.to_s.size + 2
|
|
304
|
+
#=> 4
|
|
305
|
+
puts(arr.map { |a| a.map { |i| i.round(3).to_s.rjust(width) }.join })
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Adding current fitness to the list of stats
|
|
309
|
+
def update_stats
|
|
310
|
+
@stats.push(@fitness)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
private :initial_particle_assignment, :update_p_best, \
|
|
314
|
+
:generate_random_vector, :subtract_position, :sum_probability_matrices
|
|
315
|
+
|
|
316
|
+
end
|
|
317
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MBPSOTeamFormation
|
|
4
|
+
class Validation
|
|
5
|
+
def raise_arg_error(text, condition)
|
|
6
|
+
raise ArgumentError, text unless condition
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def validate_number(var, name, type)
|
|
10
|
+
case type
|
|
11
|
+
when 'pos_int'
|
|
12
|
+
text = "Argument '#{name}' is not a valid positive Integer"
|
|
13
|
+
condition = (var.is_a?(Integer) &&
|
|
14
|
+
var.positive?)
|
|
15
|
+
when 'nn_num'
|
|
16
|
+
text = "Argument '#{name}' is not a valid non-negative integer or float"
|
|
17
|
+
condition = ((var.is_a?(Integer) ||
|
|
18
|
+
var.is_a?(Float)) &&
|
|
19
|
+
(var >= 0))
|
|
20
|
+
else
|
|
21
|
+
text = 'Invalid validation call'
|
|
22
|
+
condition = false
|
|
23
|
+
end
|
|
24
|
+
raise_arg_error(text, condition)
|
|
25
|
+
var
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate_survival_number(var, length)
|
|
29
|
+
text = "Argument 'survival_number' is not a valid Integer."\
|
|
30
|
+
' Integer in the range [2:Number of students] expected'
|
|
31
|
+
raise_arg_error(text, (var.is_a?(Integer) &&
|
|
32
|
+
(var >= 2) &&
|
|
33
|
+
(var <= length)))
|
|
34
|
+
var
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate_control_parameters(var, name)
|
|
38
|
+
text = "Unrecognised parameter, 'local' or 'personal' required."
|
|
39
|
+
raise_arg_error(text, ((name.is_a? String) &&
|
|
40
|
+
(%w[local personal].include? name)))
|
|
41
|
+
|
|
42
|
+
text = "Argument '#{name}' is not in the required format."\
|
|
43
|
+
' Array with 3 floats in the range [0;1] expected'
|
|
44
|
+
|
|
45
|
+
raise_arg_error(text, ((var.is_a? Array) && var.length == 3))
|
|
46
|
+
|
|
47
|
+
raise_arg_error(text, (var[0].is_a?(Float) || Integer &&
|
|
48
|
+
(var[0] <= 1) &&
|
|
49
|
+
(var[0] >= 0)))
|
|
50
|
+
|
|
51
|
+
raise_arg_error(text, (var[1].is_a?(Float) || Integer &&
|
|
52
|
+
(var[1] <= 1) &&
|
|
53
|
+
(var[1] >= 0)))
|
|
54
|
+
|
|
55
|
+
raise_arg_error(text, (var[2].is_a?(Float) || Integer &&
|
|
56
|
+
(var[2] <= 1) &&
|
|
57
|
+
(var[2] >= 0)))
|
|
58
|
+
var
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_skill_table(var)
|
|
62
|
+
text = "Argument 'skill_table' has invalid value and/or invalid coverage of the grade range"
|
|
63
|
+
temp = []
|
|
64
|
+
|
|
65
|
+
# Making sure the parameter is the right type
|
|
66
|
+
raise_arg_error(text, (var.is_a? Hash))
|
|
67
|
+
# Expanding the ranges and adding them into a temporary array
|
|
68
|
+
var.each_key.each do |key|
|
|
69
|
+
# Making sure the keys of the Hash are valid Integer ranges,
|
|
70
|
+
# as Range can be a String one, for example
|
|
71
|
+
raise_arg_error(text, (key.is_a?(Range) &&
|
|
72
|
+
key.begin.is_a?(Integer) &&
|
|
73
|
+
key.end.is_a?(Integer)))
|
|
74
|
+
temp.append(*key)
|
|
75
|
+
end
|
|
76
|
+
temp = temp.sort
|
|
77
|
+
# Right size ==> No duplicates and full range covered
|
|
78
|
+
raise_arg_error(text, (temp.length == 101))
|
|
79
|
+
# Starts with zero
|
|
80
|
+
raise_arg_error(text, temp[0].zero?)
|
|
81
|
+
# Ends with 100
|
|
82
|
+
raise_arg_error(text, temp.last == 100)
|
|
83
|
+
|
|
84
|
+
# Checking the skill values grades will be mapped to
|
|
85
|
+
var.values.each do |x|
|
|
86
|
+
raise_arg_error(text,\
|
|
87
|
+
(x.is_a?(Integer) &&
|
|
88
|
+
(x >= 0)))
|
|
89
|
+
end
|
|
90
|
+
var
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_bool(var, name)
|
|
94
|
+
text = "Argument '#{name}' is not in the required format."\
|
|
95
|
+
' Boolean expected'
|
|
96
|
+
raise_arg_error(text,\
|
|
97
|
+
([true, false].include? var))
|
|
98
|
+
var
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_dataset(var)
|
|
102
|
+
text = "Invalid format of data set. Required: 'CSV::var'"
|
|
103
|
+
raise_arg_error(text, (var.is_a? CSV::Table))
|
|
104
|
+
|
|
105
|
+
text = 'Ivalid number of columns, required 4'
|
|
106
|
+
raise_arg_error(text, (var.headers.size == 4))
|
|
107
|
+
|
|
108
|
+
text = "Invalid Headers. Required: 'id', 'Gender', 'Ethnicity' and 'Grade'"
|
|
109
|
+
raise_arg_error(text, (var.headers.include?('id') &&
|
|
110
|
+
var.headers.include?('Gender') &&
|
|
111
|
+
var.headers.include?('Ethnicity') &&
|
|
112
|
+
var.headers.include?('Grade')))
|
|
113
|
+
|
|
114
|
+
text = 'The data set contains duplicating student IDs'
|
|
115
|
+
raise_arg_error(text,\
|
|
116
|
+
(var['id'].uniq.length == var['id'].length))
|
|
117
|
+
|
|
118
|
+
# Regular expressions for each attribute
|
|
119
|
+
gender_regex = /^(-1|0|1)$/
|
|
120
|
+
ethnicity_regex = /^(-1|[0-4])$/
|
|
121
|
+
grade_regex = /^(100|[1-9]?[0-9])$/
|
|
122
|
+
|
|
123
|
+
(0..var.length - 1).each do |x|
|
|
124
|
+
text_gender = "Invalid gender value for student with ID = #{var[x]['id']}"\
|
|
125
|
+
'.Required: integer in the range [-1:1]'
|
|
126
|
+
text_ethn = "Invalid ethnicity value for student with ID = #{var[x]['id']}"\
|
|
127
|
+
'.Required: integer in the range [-1:4]'
|
|
128
|
+
text_grade = "Invalid grade value for student with ID = #{var[x]['id']}"\
|
|
129
|
+
'.Required: integer in the range [0:100].'
|
|
130
|
+
|
|
131
|
+
raise_arg_error(text_gender,\
|
|
132
|
+
(var[x]['Gender'].to_s =~ gender_regex ||
|
|
133
|
+
var[x]['Gender'].nil?))
|
|
134
|
+
raise_arg_error(text_ethn,\
|
|
135
|
+
(var[x]['Ethnicity'].to_s =~ ethnicity_regex ||
|
|
136
|
+
var[x]['Ethnicity'].nil?))
|
|
137
|
+
raise_arg_error(text_grade,\
|
|
138
|
+
(var[x]['Grade'].to_s =~ grade_regex ||
|
|
139
|
+
var[x]['Grade'].nil?))
|
|
140
|
+
end
|
|
141
|
+
var
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_forbidden_pairs(var)
|
|
145
|
+
text = "Invalid format of the 'forbidden_pairs' argument."\
|
|
146
|
+
'Array or CSV::Table required.'
|
|
147
|
+
raise_arg_error(text,\
|
|
148
|
+
((var.is_a? Array) ||
|
|
149
|
+
(var.is_a? CSV::Table)))
|
|
150
|
+
|
|
151
|
+
flag = true
|
|
152
|
+
(0..var.length - 1).each do |x|
|
|
153
|
+
flag = false unless var[x].length == 2
|
|
154
|
+
text = "Invalid size of sub-array at index #{x}."\
|
|
155
|
+
' Pair, i.e. array of two elements required.'
|
|
156
|
+
raise_arg_error(text, flag)
|
|
157
|
+
end
|
|
158
|
+
var
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private :raise_arg_error
|
|
162
|
+
end
|
|
163
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: MBPSO_Team_Formation
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- 'Anton Pashov '
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2020-04-21 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description:
|
|
14
|
+
email:
|
|
15
|
+
- anton.pashov@kcl.ac.uk
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- lib/MBPSO_Team_Formation.rb
|
|
21
|
+
- lib/MBPSO_Team_Formation/mbpso.rb
|
|
22
|
+
- lib/MBPSO_Team_Formation/mvh.rb
|
|
23
|
+
- lib/MBPSO_Team_Formation/neighbourhood.rb
|
|
24
|
+
- lib/MBPSO_Team_Formation/particle.rb
|
|
25
|
+
- lib/MBPSO_Team_Formation/validation.rb
|
|
26
|
+
- lib/MBPSO_Team_Formation/version.rb
|
|
27
|
+
homepage: https://github.kcl.ac.uk/k1631446/MBPSO_Team_Formation
|
|
28
|
+
licenses:
|
|
29
|
+
- MIT
|
|
30
|
+
metadata:
|
|
31
|
+
homepage_uri: https://github.kcl.ac.uk/k1631446/MBPSO_Team_Formation
|
|
32
|
+
source_code_uri: https://github.kcl.ac.uk/k1631446/MBPSO_Team_Formation
|
|
33
|
+
post_install_message:
|
|
34
|
+
rdoc_options: []
|
|
35
|
+
require_paths:
|
|
36
|
+
- lib
|
|
37
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
38
|
+
requirements:
|
|
39
|
+
- - ">="
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: 2.3.0
|
|
42
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
requirements: []
|
|
48
|
+
rubygems_version: 3.0.3
|
|
49
|
+
signing_key:
|
|
50
|
+
specification_version: 4
|
|
51
|
+
summary: Automated team formation using modified binary particle swarm optimisation.
|
|
52
|
+
test_files: []
|