Ciencia de datos en casa: cómo resolver el problema de los horarios de las niñeras con algoritmos genéticos y de Montecarlo | por Courtney Perigo | Sep, 2024

Armado con la simulación de todas las formas posibles en que nuestro cronograma puede lanzarnos bolas curvas, supe que era hora de aplicar algunas técnicas de optimización de gran impacto. Entran en escena los algoritmos genéticos, un método de optimización inspirado en la selección natural que encuentra la mejor solución mediante la evolución iterativa de una población de soluciones candidatas.

Foto de Sangharsh Lohakare en Dejar de salpicar

En este caso, cada “candidato” era un conjunto potencial de características de niñera, como su disponibilidad y flexibilidad. El algoritmo evalúa diferentes características de la niñera y las mejora iterativamente para encontrar la que se adapta a las necesidades de nuestra familia. ¿El resultado? Una niñera altamente optimizada con preferencias de programación que equilibran nuestras brechas de cobertura parental con la disponibilidad de la niñera.

En el centro de este enfoque se encuentra lo que me gusta llamar el “cromosoma niñera”. En términos de algoritmos genéticos, un cromosoma es simplemente una forma de representar posibles soluciones; en nuestro caso, diferentes características de la niñera. Cada “cromosoma niñera” tenía un conjunto de características que definían su horario: la cantidad de días por semana que la niñera podía trabajar, el máximo de horas que podía cubrir en un día y su flexibilidad para adaptarse a diferentes horarios de inicio. Estas características eran los componentes básicos de cada posible horario de niñera que el algoritmo consideraría.

Definición del cromosoma niñera

En los algoritmos genéticos, un “cromosoma” representa una posible solución y, en este caso, es un conjunto de características que definen el horario de una niñera. Así es como definimos las características de una niñera:

# Function to generate nanny characteristics
def generate_nanny_characteristics():
return {
'flexible': np.random.choice([True, False]), # Nanny's flexibility
'days_per_week': np.random.choice([3, 4, 5]), # Days available per week
'hours_per_day': np.random.choice([6, 7, 8, 9, 10, 11, 12]) # Hours available per day
}

El horario de cada niñera se define por su flexibilidad (si puede ajustar el horario de inicio), la cantidad de días que está disponible por semana y la cantidad máxima de horas que puede trabajar por día. Esto le da al algoritmo la flexibilidad para evaluar una amplia variedad de horarios potenciales.

Elaboración del horario para cada niñera

Una vez definidas las características de la niñera, necesitamos generar una programación semanal que se ajuste a esas restricciones:

# Function to calculate a weekly schedule based on nanny's characteristics
def calculate_nanny_schedule(characteristics, num_days=5):
shifts = []
for _ in range(num_days):
start_hour = np.random.randint(6, 12) if characteristics['flexible'] else 9 # Flexible nannies have varying start times
end_hour = start_hour + characteristics['hours_per_day'] # Calculate end hour based on hours per day
shifts.append((start_hour, end_hour))
return shifts # Return the generated weekly schedule

Esta función crea el horario de una niñera en función de su flexibilidad y horario laboral definidos. Las niñeras flexibles pueden empezar entre las 6:00 y las 12:00 horas, mientras que otras tienen horarios fijos que empiezan y terminan a horas determinadas. Esto permite que el algoritmo evalúe una variedad de horarios semanales posibles.

Seleccionar los mejores candidatos

Una vez que hemos generado una población inicial de horarios de niñeras, utilizamos una función de aptitud para evaluar cuáles se adaptan mejor a nuestras necesidades de cuidado infantil. Los horarios más adecuados se seleccionan para pasar a la siguiente generación:

# Function for selection in genetic algorithm
def selection(population, fitness_scores, num_parents):
# Normalize fitness scores and select parents based on probability
min_fitness = np.min(fitness_scores)
if min_fitness < 0:
fitness_scores = fitness_scores - min_fitness

fitness_scores_sum = np.sum(fitness_scores)
probabilities = fitness_scores / fitness_scores_sum if fitness_scores_sum != 0 else np.ones(len(fitness_scores)) / len(fitness_scores)

# Select parents based on their fitness scores
selected_parents = np.random.choice(population, size=num_parents, p=probabilities)
return selected_parents

En el paso de selección, el algoritmo evalúa la población de horarios de niñeras utilizando una función de aptitud que mide qué tan bien se alinea la disponibilidad de la niñera con las necesidades de la familia. Los horarios más adecuados, aquellos que cubren mejor las horas requeridas, son seleccionados para convertirse en “padres” de la próxima generación.

Añadiendo mutaciones para mantener las cosas interesantes

Para evitar quedarnos atrapados en soluciones subóptimas, añadimos un poco de aleatoriedad mediante la mutación. Esto permite que el algoritmo explore nuevas posibilidades modificando ocasionalmente el cronograma de la niñera:

# Function to mutate nanny characteristics
def mutate_characteristics(characteristics, mutation_rate=0.1):
if np.random.rand() < mutation_rate:
characteristics['flexible'] = not characteristics['flexible']
if np.random.rand() < mutation_rate:
characteristics['days_per_week'] = np.random.choice([3, 4, 5])
if np.random.rand() < mutation_rate:
characteristics['hours_per_day'] = np.random.choice([6, 7, 8, 9, 10, 11, 12])
return characteristics

Al introducir pequeñas mutaciones, el algoritmo puede explorar nuevos esquemas que de otro modo no se habrían considerado. Esta diversidad es importante para evitar óptimos locales y mejorar la solución a lo largo de varias generaciones.

Evolucionando hacia el horario perfecto

El paso final fue la evolución. Una vez que se han realizado la selección y la mutación, el algoritmo genético se repite a lo largo de varias generaciones y desarrolla mejores programas de niñera en cada ronda. Así es como implementamos el proceso de evolución:

# Function to evolve nanny characteristics over multiple generations
def evolve_nanny_characteristics(all_childcare_weeks, population_size=1000, num_generations=10):
population = [generate_nanny_characteristics() for _ in range(population_size)] # Initialize the population

for generation in range(num_generations):
print(f"\n--- Generation {generation + 1} ---")

fitness_scores = []
hours_worked_collection = []

for characteristics in population:
fitness_score, yearly_hours_worked = fitness_function_yearly(characteristics, all_childcare_weeks)
fitness_scores.append(fitness_score)
hours_worked_collection.append(yearly_hours_worked)

fitness_scores = np.array(fitness_scores)

# Find and store the best individual of this generation
max_fitness_idx = np.argmax(fitness_scores)
best_nanny = population[max_fitness_idx]
best_nanny['actual_hours_worked'] = hours_worked_collection[max_fitness_idx]

# Select parents and generate a new population
parents = selection(population, fitness_scores, num_parents=population_size // 2)
new_population = []
for i in range(0, len(parents), 2):
parent_1, parent_2 = parents[i], parents[i + 1]
child = {
'flexible': np.random.choice([parent_1['flexible'], parent_2['flexible']]),
'days_per_week': np.random.choice([parent_1['days_per_week'], parent_2['days_per_week']]),
'hours_per_day': np.random.choice([parent_1['hours_per_day'], parent_2['hours_per_day']])
}
child = mutate_characteristics(child)
new_population.append(child)

population = new_population # Replace the population with the new generation

return best_nanny # Return the best nanny after all generations

Aquí, el algoritmo evoluciona a lo largo de varias generaciones, seleccionando los mejores horarios de niñera en función de sus puntuaciones de aptitud y permitiendo que surjan nuevas soluciones mediante la mutación. Después de varias generaciones, el algoritmo converge en el mejor horario de niñera posible, optimizando la cobertura para nuestra familia.

Reflexiones finales

Con este enfoque, aplicamos algoritmos genéticos para mejorar iterativamente los horarios de las niñeras, asegurándonos de que el horario seleccionado pudiera manejar el caos de los turnos de trabajo impredecibles del Padre 2 y, al mismo tiempo, equilibrar las necesidades de nuestra familia. Los algoritmos genéticos pueden haber sido excesivos para la tarea, pero nos permitieron explorar varias posibilidades y optimizar la solución con el tiempo.

Las imágenes que aparecen a continuación describen la evolución de los índices de aptitud física de las niñeras a lo largo del tiempo. El algoritmo logró converger rápidamente en el mejor cromosoma de la niñera después de solo unas pocas generaciones.

Imagen especial del autor
Imagen especial del autor