ЛР-03: Базовый пример распределения публичного бюджета

ЛР-03: Базовый пример распределения публичного бюджета#

Worked example: civil 01#

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

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

Разобранный пример, где duality используется как инструмент чтения оптимума.

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

Ресурс

Лимит

Бюджет

88

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

52

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

42

Программы#

Программа

Эффект

Бюджет

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

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

Мобильные медицинские бригады

90

40

20

16

Школьное питание

78

28

24

12

Цифровые наборы

70

22

12

10

Зимние центры поддержки

96

48

26

20

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([90, 78, 70, 96], dtype=float)
A_ub = np.array([[40, 28, 22, 48], [20, 24, 12, 26], [16, 12, 10, 20]], dtype=float)
b_ub = np.array([88, 52, 42], 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): 226.7358
Оптимальное значение dual: 226.7358

Оптимальный план:
программа x* эффект на единицу
0 Мобильные медицинские бригады 1.0000 90.0
1 Школьное питание 0.6698 78.0
2 Цифровые наборы 1.0000 70.0
3 Зимние центры поддержки 0.1509 96.0
Ресурсный разбор:
ресурс лимит slack shadow_price binding
0 Бюджет 88.0 0.0000 0.6509 True
1 Трудозатраты 52.0 0.0000 2.4906 True
2 Операционная ёмкость 42.0 4.9434 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 1.3019 1.3019 -0.0000
1 Трудозатраты +3 Трудозатраты 3 7.4717 6.7642 -0.7075
objective_label = 'Мобильные бригады +6 к эффекту'
objective_index = 0
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 к эффекту 232.7358 6.0 1.0

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

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

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

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

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