遗传算法对”到家“服务派单进行优化

760 阅读6分钟

引言

遗传算法是一种传统的启发式算法,常用于求解作业调度、路径规划、资源分配等组合优化问题。使用启发式算法求解的步骤一般为:

  1. 定义好优化目标函数、解的形式和迭代用的算子;
  2. 进行多轮贪心迭代,在每一轮迭代中保留局部最优解;
  3. 在满足迭代终止条件后输出一个逼近全局最优的解。

对于遗传算法,目标函数为适应度函数;用一条染色体来代表一个解;迭代用的算子有交叉、变异等。下面我们将介绍一个“到家服务”的派单场景,然后使用遗传算法进行求解。完整代码:github.com/jiafeiwang/…

环境:C++ 11

1. 业务场景介绍

有一个上门服务的业务,用户需提前预约,预约后生成一个任务。要求每天给各任务分配师傅,达到以下目的:

  1. 给每个用户分配的师傅评分(score)尽可能高;
  2. 每个师傅在完成所分配的任务时走的路程(distance)尽可能短;
  3. 师傅完成上一单去下一单的时间间隔(interval)尽可能短。

提供的数据有:
任务(task): taskID、任务的开始时间、任务耗费时长、任务对应用户的地理位置坐标;
师傅(worker): workerID、评分、初始地理位置坐标。

其中位置坐标单位为米,评分取值范围0~1,预约时间粒度为30分钟

约束条件:

  1. worker要在预约时间前到达,不能迟到;
  2. 每个task只需分配一个worker,但一个worker可先后完成多个task;
  3. 每个worker负责的多个任务在开始时间与结束时间上不能冲突;
  4. 地理坐标下任意两点的距离为欧式距离;
  5. 每个worker在路上行驶速度为相同的固定值;

目标函数

需要优化worker评分(score)、路程(distance)、worker相邻两单时间间隔(interval);目标函数为:

worthy = α*Score + β*distance + γ*interval;

式中0<α<1、-1<β<0、-1<γ<0为系数。

2. 算法设计

染色体设计

image.png

图-1 染色体结构示意图

染色体设计如图-1所示:每条染色体由多个worker片段构成,每个woker片段上有相同数量的时间槽(slot),在时间槽中进行task分配。由于时间颗粒度为半小时,且按天分配,时间槽数量(slotNum)最多48。本场景下,预约时间6:00~20:00,最晚一个task完成时间为21:00,时间槽个数为30。染色体长度length=slotNum*workerNum

算法中需要对每个slot进行基因(gene)编码,方便在较低计算开销下对task进行相关操作。在本例中我们采用二进制方式编码,单个gene中需要编入slot、workerID、taskID、task所需时长(required)信息。本例中task数量为1978、worker数量为1209,32位无符号整数足够对这些信息进行编码。如果task、worker数量太多,32位不够用可以换64位无符号整数。taskID、workerID、required的起始值为1,如果slot上没分配task,对应的taskID、required值为0。

图-2展示了taskID = 1124, required = 3, workerID = 892, slotID = 3信息的编码值geneVal=1757966211。

image.png

图-2 gene编码结构示意图

其中slotId占5位、workerId占12位、taskId占12位、required占3位。
static constexpr int SLOT_BITS = 5; //用于时间槽编码的位数,共5位0~32最大值31
static constexpr int WORKER_BITS = 12; //用于workerId编码的位数
static constexpr int TASK_ID_BITS = 12; //用于taskId编码的位数
static constexpr int TASK_REQ_BITS = 3; //用于task required编码的位数

static constexpr int IDX_BITS = SLOT_BITS+WORKER_BITS;
static constexpr int TASK_BITS = TASK_ID_BITS+TASK_REQ_BITS;

struct Gene{
    u32 val; //值

    Gene();
}

task在各个slot上的基因操作有移动(move):将目标task移动到空的slot上:

void operator>(Gene &gene, Gene &whiteGene){
    if(!whiteGene.isWhite()){
        std::cout<<"gene "<<whiteGene.val<<" is not white"<<std::endl;
        throw 100;
    }

//    获取task信息
    u32 taskInfo = gene.val>>IDX_BITS<<IDX_BITS;

//    将task信息移入到whiteGene上
    whiteGene.val|=taskInfo;
    gene.val<<=TASK_BITS;
    gene.val>>=TASK_BITS;
}

交换操作(exchange),交换两个slot上task信息,如果其中一个slot上task信息为空,效果等同于move操作:

void operator*(Gene &gene1, Gene &gene2){
    u32 taskInfo_1 = gene1.val>>IDX_BITS<<IDX_BITS;
    u32 taskInfo_2 = gene2.val>>IDX_BITS<<IDX_BITS;

//    交换task信息
    gene1.val<<= TASK_BITS;
    gene1.val>>= TASK_BITS;

    gene2.val<<= TASK_BITS;
    gene2.val>>= TASK_BITS;

    gene1.val|=taskInfo_2;
    gene2.val|=taskInfo_1;
}

生成初代种群

在满足各个约束条件前提下,为每个task随机分配worker,生成初代种群染色体。单条染色体的生成方式如下:

void Chromosome::InitialChromosome(const std::vector<Task> &tasks, const std::vector<Worker> &workers, int maxCkTimes) {
    taskNum = tasks.size();
//    tasks 的idx随机打乱,用来随机分配
    std::vector<int> idx;
    for(int i=0; i<taskNum; i++){
        idx.push_back(i);
    }

    std::random_shuffle(idx.begin(),idx.end());

    for(int i=0; i<taskNum; i++){
        Task task = tasks[idx[i]];
        u32 taskId = task.getId();
        u32 startSlot = task.getStartSlot();
        u32 required = task.getRequired();

//        随机选取对应时间槽空闲的worker
        u32 workerId = randChooseFreeWid(task, startSlot, required, tasks, maxCkTimes);

        int endSlot = startSlot+required;
        int prod = (workerId-1)*slotNum;

//        task worker信息编码进对应gene
        for(int slot=startSlot; slot<endSlot; slot++){
            genes[prod+slot] = Gene::geneEncode(slot, workerId, taskId, required);
        }
        taskMp[taskId] = workerId;
    }

//    重置tag和fitnessVal
    isInitial=true;
    fitness(tasks, workers);
}

通过迭代生成多条染色体得到初代种群:

void Ga::initialChromosome(int maxCkTimes) {
    for(int i=0; i<heldChromosomeNum; i++){
//        初始化
        Chromosome chromosome = Chromosome(slotNum, workerNum);
        chromosome.InitialChromosome(tasks, workers, maxCkTimes);
        chromosomes.push_back(chromosome);
    }
//    更新tag
    isInitial = true;
}

遗传操作:

与基因级别的操作类似,染色体的遗传操作有移动(Move)、交换(Exchange)、选择(Select),Move、Exchange操作都在单条染色体基因间进行,不涉及染色体间的操作,归结到基因层面都是调用上面介绍的基因操作代码中重载的“ * ”运算符,只在约束条件判定上有区别:

//        随机选取目标task
    u32 targetWid;
    // 每propFreq次进行一次randChoosePropWid, Prop开销比Free大
    if(propFreq>0&&i%propFreq==propFreq-1){
        targetWid = chromosome.randChoosePropWid(task, startSlot, required, tasks, maxCkTimes);
    }else{
        targetWid = chromosome.randChooseFreeWid(task, startSlot, required, tasks, maxCkTimes);
    }
//        记录两个worker下操作前的fitness之和
    double fitnessPro = chromosome.workerFitness(workers[rawWid-1],tasks) +
            chromosome.workerFitness(workers[targetWid-1],tasks);

    mutateSingle(chromosome, taskId, targetWid, startSlot, endSlot);

//        记录操作后的fitness之和
    double fitnessAfter = chromosome.workerFitness(workers[rawWid-1],tasks) +
                        chromosome.workerFitness(workers[targetWid-1],tasks);

//        如果反倒变差了就交换回去,等同于此轮不做操作
    if(fitnessAfter<fitnessPro){
        mutateSingle(chromosome, taskId, rawWid, startSlot, endSlot);
    }

move操作用randChooseFreeWid随机选取workerID,exchange操作用randChoosePropWid随机选取workerID。如果操作后fitnessVal提升了,则对操作进行保留,没有提升再进行一次“*”运算换回到原状态。调用randChooseFreeWid、mutateSingle开销很小,但是调用randChoosePropWid开销较大,我们采用参数propFreq控制每多少次迭代进行一次randChoosePropWid,选取exchange操作所用的workerId。

最后根据fitnessVal对chromosome排序,保留fitnessVal高的染色体:

void Ga::select() {
//    按fitness排序
    std::sort(chromosomes.begin(), chromosomes.end(),[](Chromosome chromosome1, Chromosome chromosome2){
        return chromosome1.fitnessVal>chromosome2.fitnessVal;
    });
//    选取最优
    chromosomes.resize(heldChromosomeNum);
}

3. 实验结果

不同迭代次数

条件:
heldChromosomeNum=5,每个epoch下保留5条染色体;
mutateTimes=10,每个epoch下保留的染色体随机抽取10次;
propFreq=10,每次操作抽取一条染色体,在操作中每10次迭代进行1次Exchange操作,其他9次进行Move。

图-3展示单条染色体迭代次数(taskMtTimes)分别为1万、5万、10万的优化结果:

image.png

图-3 fitnessVal epoch分布图

taskMtTimes=10w,50个epoch后fitness收敛在0.691。在相同迭代次数下,Exchange操作比Move操作效果要更好,虽然说一次Exchange操作在原理上等效于多次Move操作。如果不进行Exchange操作只单纯进行move操作,结果会稍差,对比如下表所示:

image.png

表-1 纯Move操作和Move+1/10Exchange操作结果对比

预留染色体数量

预留染色体数量(heldChromosomeNum)过多或过少都会影响最终结果,单条染色体上操作较少、影响程度越大。

image.png

表-2 不同heldChromosomeNum-taskMtTimes结果对比

不同迭代策略

在每个epoch迭代次数相同前提下,两种不同的策略:mutateTimes=20, taskMtTimes=5w与mutateTimes=10, taskMtTimes=10w结果对比如图-4所示,10x10w迭代收敛更快,但最终结果差异不大。两种策略区别在于:mutate级别操作可以并行,而taskMt不能并行。如果使用了并行计算,高的mutateTimes可以缩短运行时间。

image.png

图-4 不同迭代策略20x5w与10x10wfitness对比

4. 小结

遗传算法求解思路其实很简单,关键是染色体怎么设计,遗传操作算子如何定义;这两个问题解决之后剩下的就是顺着适应度升高的方向暴力迭代了。