Ciw icon indicating copy to clipboard operation
Ciw copied to clipboard

[Feature Request] Time/State-Dependent Baulking

Open galenseilis opened this issue 2 years ago • 2 comments

I noticed that arrival_node.ArrivalNode.decide_baulk is responsible for making a decision about whether to baulk an instance of Individual.

Here is the implementation:

    def decide_baulk(self, next_node, next_individual):
        """
        Either makes an individual baulk, or sends the individual
        to the next node.
        """
        if next_node.baulking_functions[self.next_class] is None:
            self.send_individual(next_node, next_individual)
        else:
            rnd_num = random()
            if rnd_num < next_node.baulking_functions[self.next_class](next_node.number_of_individuals):
                self.record_baulk(next_node, next_individual)
                self.simulation.nodes[-1].accept(next_individual, completed=False)
            else:
                self.send_individual(next_node, next_individual)

Baulking is conventionally thought of as customers deciding not to join the queue if it is too long, which is may be why it was implemented this way. I first learn from the docs that the baulking function just takes the length of the queue and returns a probability of baulking.

Here is the example from the documentation:

def probability_of_baulking(n):
    if n < 3:
        return 0.0
    if n < 7:
        return 0.5
    return 1.0

That function actually uses n. We could of course make baulking functions that just ignore n. Here is a frivolous example that we would not be interested in practice. In this case there is a probability of 1 that the individual will baulk if the int of the unix time is prime, otherwise there is a 0.5 probability of baulking.

import math
import time

def is_prime(n):
    if n % 2 == 0 and n > 2: 
        return False
    return all(n % i for i in range(3, int(math.sqrt(n)) + 1, 2))

def probability_of_baulking(n):
    int_unix_time = int(time.time())
    
    if is_prime(int_unix_time):
        return 1.0

    return 0.5

But what is of practical interest to me is baulking that depends on time, or the individual's properties, or other properties of next_node, or even some entirely non-local properties. And I don't want to patch this unless I have to.

How feasible is replacing

next_node.baulking_functions[self.next_class](next_node.number_of_individuals)

with

next_node.baulking_functions[self.next_class](next_node, next_individual)?

Having next_node available is a handy default behaviour and otherwise anything about time or state can be accessed via next_individual.simulation. It would allow for a form of "generalized baulking" where the reason for not joining the queue could be for reasons other than the length of the waitlist.

I realize there pretty much the same thing could be achieved via state-dependent routing.

galenseilis avatar Dec 18 '23 20:12 galenseilis

Hi, I love this idea. In fact, the baulking function could just take in the simulation object itself, and then have access to any property of the system it needs, including the time?

e.g.

def probability_of_baulking(Q, node_id):
    if len(Q.nodes[node_id].all_customers) < 3:
        return 0.0
    if len(Q.nodes[node_id].all_customers) < 7:
        return 0.5
    return 1.0

What do you think?

geraintpalmer avatar Dec 19 '23 15:12 geraintpalmer

Hi, I love this idea. In fact, the baulking function could just take in the simulation object itself, and then have access to any property of the system it needs, including the time?

e.g.

def probability_of_baulking(Q, node_id):
    if len(Q.nodes[node_id].all_customers) < 3:
        return 0.0
    if len(Q.nodes[node_id].all_customers) < 7:
        return 0.5
    return 1.0

What do you think?

Thank you for considering my ideas! 😊

I can see how passing the simulation to probability_of_baulking naturally gives access to the top-level. From there the time and state can be accessed. I'm wondering, how would probability_of_baulking know which individual is being considered for baulking. That would be important for individual-dependent baulking behaviour. Would there be a natural way to access that information if the function signature was probability_of_baulking(Q, node_id)?

Here is my thinking for the function signature I suggested. Using the probability_of_baulking(next_node, next_individual) signature would communicate the pair of node and individual in consideration. In my opinion this is a natural default. However, if the time was needed, one could always use next_individual.simulation.current_time since every individual has a reference of simulation as an instance attribute. Similarly, next_individual.simulation.nodes should give access to arbitrary state information.

I suppose another option would be something like probability_of_baulking(Q, node_id, ind_id), but that means performing a search for the instance of the next node if we wanted it. It doesn't seem like a good option.

I know, I am slightly modifying the term type signature to a related note I am terming "function signature". I really am just talking about what parameters the function has.

Further discussion on this is welcome! I am still unfamiliar with most of the mechanisms in Ciw, so I may be missing something.

galenseilis avatar Dec 19 '23 17:12 galenseilis

Implemented here: 9d00262de505e163405f135bd630f26278fee959

geraintpalmer avatar Apr 03 '24 15:04 geraintpalmer