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.
@@ -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,9 @@
1
+
2
+ module MBPSOTeamFormation
3
+ require 'MBPSO_Team_Formation/version'
4
+ require 'MBPSO_Team_Formation/mbpso'
5
+ require 'MBPSO_Team_Formation/validation'
6
+ require "MBPSO_Team_Formation/neighbourhood"
7
+ require "MBPSO_Team_Formation/particle"
8
+
9
+ end
@@ -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
@@ -0,0 +1,3 @@
1
+ module MBPSOTeamFormation
2
+ VERSION = "0.1.0"
3
+ 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: []