ЛР-03: Бюджет медицинской эвакуационной поддержки

ЛР-03: Бюджет медицинской эвакуационной поддержки#

Worked example: military 03#

Это полностью разобранный example по duality и анализу чувствительности. Он показывает полный цикл от прямой модели до сценарных пересчётов.

1. Исходный кейс#

Пример про распределение ресурсов между направлениями медико-эвакуационной поддержки.

Ограничения ресурсов#

Ресурс

Лимит

Бюджет

91

Медперсонал

57

Операционная ёмкость

45

Программы#

Программа

Эффект

Бюджет

Трудозатраты

Операционная ёмкость

Эвакуационные модули

89

38

20

15

Стабилизационные пункты

85

34

18

14

Запас комплектов крови

73

22

11

9

Мобильная диагностика

77

26

15

12

import numpy as np
import pandas as pd
from scipy.optimize import linprog

def solve_primal(effects, A_ub, b_ub):
    result = linprog(-effects, A_ub=A_ub, b_ub=b_ub, bounds=[(0, 1)] * len(effects), method='highs')
    if not result.success:
        raise RuntimeError(result.message)
    shadow_prices = -result.ineqlin.marginals
    return result, shadow_prices

def solve_dual(effects, A_ub, b_ub):
    m, n = A_ub.shape
    c_dual = np.concatenate([b_ub, np.ones(n)])
    A_dual = -np.hstack([A_ub.T, np.eye(n)])
    b_dual = -effects
    result = linprog(c_dual, A_ub=A_dual, b_ub=b_dual, bounds=[(0, None)] * (m + n), method='highs')
    if not result.success:
        raise RuntimeError(result.message)
    return result

def rerun_with_resource_change(effects, A_ub, b_ub, resource_index, delta):
    new_b = b_ub.copy()
    new_b[resource_index] += delta
    result, _ = solve_primal(effects, A_ub, new_b)
    return new_b, result
program_names = ['Эвакуационные модули', 'Стабилизационные пункты', 'Запас комплектов крови', 'Мобильная диагностика']
resource_names = ['Бюджет', 'Медперсонал', 'Операционная ёмкость']
effects = np.array([89, 85, 73, 77], dtype=float)
A_ub = np.array([[38, 34, 22, 26], [20, 18, 11, 15], [15, 14, 9, 12]], dtype=float)
b_ub = np.array([91, 57, 45], dtype=float)

primal_result, shadow_prices = solve_primal(effects, A_ub, b_ub)
dual_result = solve_dual(effects, A_ub, b_ub)

plan_df = pd.DataFrame({
    'программа': program_names,
    'x*': np.round(primal_result.x, 4),
    'эффект на единицу': effects,
})
resources_df = pd.DataFrame({
    'ресурс': resource_names,
    'лимит': b_ub,
    'slack': np.round(primal_result.slack, 4),
    'shadow_price': np.round(shadow_prices, 4),
    'binding': np.isclose(primal_result.slack, 0.0),
})

print('Оптимальный эффект (primal):', round(-primal_result.fun, 4))
print('Оптимальное значение dual:', round(dual_result.fun, 4))
print()
print('Оптимальный план:')
display(plan_df)
print('Ресурсный разбор:')
display(resources_df)
Оптимальный эффект (primal): 256.0789
Оптимальное значение dual: 256.0789

Оптимальный план:
программа x* эффект на единицу
0 Эвакуационные модули 0.2368 89.0
1 Стабилизационные пункты 1.0000 85.0
2 Запас комплектов крови 1.0000 73.0
3 Мобильная диагностика 1.0000 77.0
Ресурсный разбор:
ресурс лимит slack shadow_price binding
0 Бюджет 91.0 0.0000 2.3421 True
1 Медперсонал 57.0 8.2632 0.0000 False
2 Операционная ёмкость 45.0 6.4474 0.0000 False
scenario_rows = []
for label, resource_index, delta in [
    ('Бюджет +2', 0, 2),
    ('Медперсонал +3', 1, 3),
]:
    new_b, new_result = rerun_with_resource_change(effects, A_ub, b_ub, resource_index, delta)
    predicted = shadow_prices[resource_index] * delta
    actual = (-new_result.fun) - (-primal_result.fun)
    scenario_rows.append({
        'сценарий': label,
        'ресурс': resource_names[resource_index],
        'delta': delta,
        'прогноз по shadow price': round(predicted, 4),
        'факт после пересчёта': round(actual, 4),
        'разница': round(actual - predicted, 4),
    })

scenario_df = pd.DataFrame(scenario_rows)
display(scenario_df)
сценарий ресурс delta прогноз по shadow price факт после пересчёта разница
0 Бюджет +2 Бюджет 2 4.6842 4.6842 0.0
1 Медперсонал +3 Медперсонал 3 0.0000 0.0000 0.0
objective_label = 'Мобильная диагностика +6 к эффекту'
objective_index = 3
objective_delta = 6

new_effects = effects.copy()
new_effects[objective_index] += objective_delta
objective_result, _ = solve_primal(new_effects, A_ub, b_ub)
objective_df = pd.DataFrame({
    'сценарий': [objective_label],
    'новый оптимальный эффект': [round(-objective_result.fun, 4)],
    'изменение эффекта': [round((-objective_result.fun) - (-primal_result.fun), 4)],
    'новое значение программы': [round(objective_result.x[objective_index], 4)],
})
display(objective_df)
сценарий новый оптимальный эффект изменение эффекта новое значение программы
0 Мобильная диагностика +6 к эффекту 262.0789 6.0 1.0

2. Что важно проговорить в выводе#

  • какие ограничения оказались binding и почему именно они держат оптимум;

  • какой ресурс имеет наибольшую теневую цену и что это означает содержательно;

  • насколько хорошо совпал прогноз по shadow price с фактическим пересчётом;

  • меняется ли структура плана при небольших изменениях b и c.