ЛР-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.