Optimizing MiniZinc constraints for a scheduling problem

Viewed 77

I am trying to use MiniZinc to solve a scheduling problem. Here is the main setting:

  • I have a certain number of students
  • They rank 7 topics in order of preference
  • They will be assigned 4 of such topics
  • There is 4 time slots available in the timetable for all of these

Of course students cannot attend two topics at the same time, and there are also a minimal/maximal size for each topic (but there may be various classes of a same topic, or no class of another topic). This is the most important constraint and we could see that as a satisfiability problem, even though trying to maximize the hapinness of the students is a good bonus.

I tried the following approach, translating all my constraints:

int: n; % number of students
int: m; % number of topics
int: c; % number of courses each student will take
int: maxseats; % number of seats available per course max
int: minseats; % number of seats minimum per course
int: timeslots; % possible time slots for the courses

set of int: STUDENTS=1..n; % students
set of int: PREFERENCE=1..m; % possible preference rankings
set of int: TOPICS=1..m; % different topics
set of int: TIMESLOTS=1..timeslots; 

array[STUDENTS,TOPICS] of PREFERENCE: preference; % ranking of topics by each student

array[STUDENTS,1..c] of var TOPICS: course; % topics assigned to each student
array[TOPICS] of var TIMESLOTS: schedule; % time slot assigned to topics

include "alldifferent.mzn";
constraint % students have different topics
  forall(student in STUDENTS)(alldifferent([course[student,i] | i in 1..c])); 
  
constraint % no student has two topics at the same time
  forall(student in STUDENTS)(forall(i,j in 1..c where i<j)(schedule[course[student,i]] != schedule[course[student,j]])); 
  
constraint % less students per topic than available seats (NOTE : groups can be duplicated)
  forall(topic in TOPICS)(sum(student in STUDENTS where exists(i in 1..c)(course[student,i] = topic))(1) <= maxseats); 
  
constraint % more students per topic than the minimum
  forall(topic in TOPICS)(sum(student in STUDENTS where exists(i in 1..c)(course[student,i] = topic))(1) >= minseats); 

var int: satisfaction = sum(student in STUDENTS)(sum(i in 1..c)(preference[student, course[student,i]])); 

solve minimize satisfaction; 

The problem is that this works for few students, but it is far from working in a reasonable time for my list of students (I have about 100, which I though was not that many, but even 20 does not reach any conclusion in ten minutes).

Here is part of my data file (cutting some students for simlplicity, but it is already too long:

n = 10;
m = 7;
c = 3; 
maxseats=18; 
minseats=1; 
timeslots=4; 

preference =[|
1,2,5,3,7,4,6|
6,4,7,5,1,2,3|
1,2,6,4,5,7,3|
5,3,1,2,6,4,7|
5,2,1,6,3,7,4|
7,5,1,6,3,2,4|
1,7,6,4,5,3,2|
1,4,2,6,3,5,7|
4,6,1,5,2,3,7|
1,6,4,3,5,7,2|
|]; 

Are there any relevant constraints or structure I can add to help the solvers to do the job?

*** Edit. Here is a further attempt. I noticed another problem: basically, groups cannot be too large (less than maxseats), thus there will be various groups of each topic (with same teacher). Hence, not only cannot students have lectures at the same time, but various groups of the same topic also ought to be distinguished, with the constraint of not having lectures at the same time. This looks like a much larger combinatorial problem! Maybe there is a clever way to package it, but for now I essentially chose to duplicate the topics.

This is the model:

    int: n; % number of students
    int: m; % number of topics
    int: c; % number of courses each student will take
    int: g=5; % number of instances of each course
    int: maxseats; % number of seats available per course max
    int: minseats; % number of seats minimum per course
    int: timeslots; % possible time slots for the courses

    set of int: STUDENTS=1..n; % students
    set of int: PREFERENCE=1..m; % possible preference rankings
    set of int: TOPICS=1..g*m; % different topics
    set of int: TIMESLOTS=0..timeslots; 

    array[STUDENTS,1..m] of PREFERENCE: preference; % ranking of topics by each student
    array[STUDENTS, PREFERENCE] of TOPICS: prefRank = array2d(STUDENTS, PREFERENCE, [rank
       | student in STUDENTS, p in 1..m, rank in 1..m where preference[student, rank] == p]);

    array[STUDENTS,1..c] of var TOPICS: course; % topics assigned to each student
    array[TOPICS] of var TIMESLOTS: schedule; % time slot assigned to topics

    function int: topicToGroupFix(var int: t) =
        fix(((t-1) mod g) + 1);
    
    function var int: topicToTopic(var int: t) =
        ((t-1) div g) + 1;
    function int: topicToTopicFix(var int: t) =
        fix(((t-1) div g) + 1);

    function int: topicGroupToTopic(int: t, int: gg) =
        ((t-1) * g) + gg;
    
    array[TOPICS] of var 0..maxseats: number; % number of people taking each course
    constraint 
      forall (topic in TOPICS)(number[topic] = sum(student in STUDENTS, i in 1..c)(course[student,i] == topic)); 

    include "globals.mzn";

    constraint % before using another topic group make sure the previous one has students in it
      forall(t in 1..m, gg in 1..g-1)( number[topicGroupToTopic(t,gg+1)] > 0 -> number[topicGroupToTopic(t,gg)] >= minseats );

    constraint % if students are assigned to a topic group it must have enough
      forall(t in TOPICS)( number[t] > 0 -> number[t] >= minseats );

   constraint % make sure topics are slotted at different times
       forall(t in 1..m)( alldifferent_except_0([schedule[x] | x in topicGroupToTopic(t,1)..topicGroupToTopic(t,g)]));

    array[STUDENTS,1..c] of var PREFERENCE: courseRank;

    constraint % channeling constraint to obtain topic ranking
      forall(student in STUDENTS, i in 1..c)(courseRank[student, i] = prefRank[student, topicToTopic(course[student, i])]);

    constraint % no student has two topics at the same time
      forall(student in STUDENTS)(alldifferent([schedule[course[student,i]] | i in 1..c]));

    constraint % students have different topics (and break symmetries)
      forall(student in STUDENTS)(strictly_increasing([courseRank[student, i] | i in 1..c]));
      
    constraint % students have different topics
      forall(student in STUDENTS)(alldifferent([topicToTopic(course[student, i]) | i in 1..c])); 

    constraint % fairness - don't want any one student to get a bad schedule relative to others
      forall(student in STUDENTS)(sum([courseRank[student,i] | i in 1..c]) <= 
                                   min([sum([courseRank[s,i] | i in 1..c]) | s in STUDENTS where s != student])+1);
    %constraint % fairness - don't want any one student to get a bad schedule relative to others
    %  forall(student in STUDENTS)(sum([courseRank[student,i] | i in 1..c]) <= 
    %                               min([sum([courseRank[s,i] | i in 1..c]) | s in STUDENTS])+1);

    constraint % fairness - don't a student get a much worse choice than others
      forall(student in STUDENTS)(courseRank[student,c] <= min([courseRank[s,c] | s in STUDENTS where s != student])+1);
    %constraint % fairness - don't a student get a much worse choice than others
    %  forall(student in STUDENTS)(courseRank[student,c] <= min([courseRank[s,c] | s in STUDENTS])+1);

    % break symmetries on the schedule
    constraint forall(t in TOPICS)(if number[t] == 0 then schedule[t] == 0 else schedule[t] >= 1 /\ schedule[t] <= t endif);
    %constraint forall(t in 1..m)(schedule[t] <= t);
    constraint forall(t in 1..m*g-1)(schedule[t+1] <= (max(x in TOPICS)(schedule[x])+1));   
      
    var int: satisfaction = sum(student in STUDENTS, i in 1..c)(courseRank[student,i]);

    solve :: int_search(courseRank, smallest, indomain_min)
       minimize satisfaction;
    %solve minimize satisfaction;

    output(["satisfaction Level: \(satisfaction)\n"]);
    output(["student", "\t", "topic", "\t\t", "group", "\t\t", "courseRank", "\n"]);
    output([show(student) ++ "\t" ++ show([topicToTopicFix(course[student,i]) | i in 1..c]) ++ "\t" ++
                                     show([topicToGroupFix(course[student,i]) | i in 1..c]) ++ "\t" ++
            show([courseRank[student,i] | i in 1..c]) ++ "\n" | student in STUDENTS]);

    output(["studentsPerTopic: \(number)\n"]);
    output(["schedule: \([schedule[x] | x in TOPICS])\n"]);

The same data file can be used, but with 100 students, even c=1 (i.e. only one lecture to assign, which should be essentially trivial!) does not finish in hours and hours...

Is there anything I can do or is the problem really combinatorically too big for MiniZinc?

1 Answers

I tried to tweak your model:

include "globals.mzn";
int: n; % number of students
int: m; % number of topics
int: c; % number of courses each student will take
int: maxseats; % number of seats available per course max
int: minseats; % number of seats minimum per course
int: timeslots; % possible time slots for the courses

set of int: STUDENTS=1..n; % students
set of int: PREFERENCE=1..m; % possible preference rankings
set of int: TOPICS=1..m; % different topics
set of int: TIMESLOTS=1..timeslots; 
set of int: COURSES=1..c;

array[STUDENTS,TOPICS] of PREFERENCE: preference; % ranking of topics by each student

array[STUDENTS,COURSES] of var TOPICS: course; % topics assigned to each student
array[TOPICS] of var TIMESLOTS: schedule; % time slot assigned to topics

constraint % students have different topics; we thus enforce topics per student to be sorted
  forall(student in STUDENTS, i in 1..c-1)(course[student,i] < course[student,i+1]); 
  
constraint % no student has two topics at the same time
  forall(student in STUDENTS)(alldifferent([schedule[course[student,i]] | i in COURSES])); 
  
constraint % students per topic within allowed bounds (NOTE : groups can be duplicated)
  forall(topic in TOPICS)(sum([(course[student,i] == topic)| student in STUDENTS, i in COURSES]) in minseats .. maxseats); 
  
var n..n*m: dissatisfaction = sum(student in STUDENTS)(sum(i in COURSES)(preference[student, course[student,i]])); 

constraint dissatisfaction < n * 5;

Sadly, for the given dataset, the solution time is still beyond my patience.


Update:

My second attempt with Boolean decision variables which represent the presence or absence of students per topic.

include "globals.mzn";
int: n; % number of students
int: m; % number of topics
int: c; % number of courses each student will take
int: maxseats; % number of seats available per course max
int: minseats; % number of seats minimum per course
int: timeslots; % possible time slots for the courses

set of int: STUDENTS=1..n; % students
set of int: PREFERENCE=1..m; % possible preference rankings
set of int: TOPICS=1..m; % different topics
set of int: TIMESLOTS=1..timeslots; 
set of int: COURSES=1..c;

array[STUDENTS,TOPICS] of PREFERENCE: preference; % ranking of topics by each student

array[TOPICS, STUDENTS] of var bool: course; % students assigned to topics
array[TOPICS] of var TIMESLOTS: schedule; % time slot assigned to topics
 
constraint % enforce the number of courses per student
  forall(student in STUDENTS)(c = sum([course[t, student] | t in TOPICS]));
  
constraint % no student has two topics at the same time
  forall(student in STUDENTS)(alldifferent([schedule[t] | t in TOPICS where course[t, student]])); 
  
constraint % students per topic within allowed bounds (NOTE : groups can be duplicated)
  forall(topic in TOPICS)(sum([course[topic, student] | student in STUDENTS]) in minseats .. maxseats); 
  
constraint %  limit the dissatisfaction of students
  max([preference[student, t] | student in STUDENTS, t in TOPICS where course[t, student]]) < 4;

Using solver backends Chuffed, Google OR Tools or COIN-BC, this is solved in under 10 seconds.
No solution is found, if the upper limit of worst preference value is lower than 3. This is no surprise with two topics to be chosen.

Related