Optimization & MCDA — Weighted Scoring, Quick LP#

Ssometimes security teams face a problem that keeps ocurring: too many candidate controls, not enough budget, and no principled way to rank them. Stakeholders want to know why a particular control was selected over another, so the ranking method must be explainable.

Every annual budget cycle involves exactly this problem: a list of initiatives competing for limited funds, each championed by a different team, each claiming to be critical. Without a structured framework, the loudest voice or the most recent incident wins. MCDA and optimization provide an alternative grounded in explicit criteria and reproducible logic.

A typical security roadmap might list 15–20 candidate initiatives. Choosing the best subset under a fixed budget is a combinatorial problem, and intuition alone handles it poorly once you pass five or six options.

This notebook walks through three complementary approaches:

  1. Multi-Criteria Decision Analysis (MCDA) — weighted scoring across several criteria, with a sensitivity check on the weights.

  2. Greedy ROI selection — pick controls in decreasing bang-for-buck order until the budget runs out.

  3. Linear Programming (LP) — the relaxed 0-1 knapsack, which gives an upper bound on what any feasible selection can achieve.

What all three methods share is transparency. The inputs (scores, weights, costs, risk-reduction estimates) are visible and debatable, and the outputs can be stress-tested by varying assumptions.

Each method answers a slightly different question, so it’s important to understand when they agree and when they disagree.

Decision-Theoretic Foundations#

The weighted scoring approach in this notebook has deep roots in formal decision theory — specifically, multiattribute utility theory developed by Keeney and Raiffa (1976). Understanding these foundations is not required to use MCDA, but it explains why the method works and when it breaks down.

When a decision involves multiple attributes — cost, risk reduction, implementation time, compliance alignment — the question is not whether to weight them, but whether to weight them explicitly or implicitly. When a CISO says “EDR is more important than SIEM,” they have already assigned weights — they just haven’t written them down.

Keeney and Raiffa showed that if a decision-maker’s preferences are complete and transitive and the attributes are mutually preferentially independent, then there exists a value function that represents those preferences as a weighted sum over attribute scores (under risk, the matching condition is additive independence):

\[u(C) = \sum_k w_k \, u_k(x_k)\]

Two key conditions (Keeney & Raiffa 1976):

  1. Transitivity: If you prefer A to B and B to C, you must prefer A to C. Violations create “money pumps” — circular preferences that vendors can exploit (see notebook 05).

  2. Preferential independence: Your preference between two options on some attributes should not depend on the fixed levels of the others. This lets us score each attribute separately and add the scores.

When the axioms hold, weighted scoring is the unique representation (up to a positive linear rescaling) consistent with the decision-maker’s preferences. The weights are swing weights: they encode the value of moving each criterion from its worst to its best level across the candidates, not its importance in the abstract, and the normalization ensures commensurability across scales.

The axioms also reveal where MCDA can mislead:

  • Transitivity violations (discussed in Chapter 0.5 under intransitive preferences) mean the decision-maker’s implicit preferences cannot be represented by any set of consistent weights. The right response is to resolve the intransitivity before scoring, not to force a weighting that papers over the inconsistency.

  • Independence failures occur when attributes interact — for example, when the value of compliance alignment depends on the level of risk reduction already achieved. These cases require non-additive models (multiplicative utility, or multilinear forms when even that fails) that go beyond the additive form used here.

For most security control-selection problems, the additive model is a reasonable starting point. The key insight from Raiffa is that weighted scoring is the minimum formal structure needed to make multi-criteria decisions consistently, and its limitations are precisely the limitations of the underlying preference axioms.

The weights are the hard part because they force explicit trade-offs that organizations normally leave implicit.

1 — Setup#

We use decision-security’s ROI selector, pandas for tabular data, scipy’s linear programming solver, and matplotlib for visualization.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.optimize import linprog

from decision_security.synth import make_rng
from decision_security.voi import select_controls_by_roi

rng = make_rng(42)
plt.rcParams.update({
    "font.family": "serif",
    "font.size": 10,
    "axes.labelsize": 11,
    "axes.titlesize": 12,
    "xtick.labelsize": 9,
    "ytick.labelsize": 9,
    "legend.fontsize": 9,
    "figure.dpi": 150,
    "axes.spines.top": False,
    "axes.spines.right": False,
})

PRIMARY = "#1A1A1A"
ACCENT = "#E74C3C"
DARK_BG = "#34495E"
LIGHT_GRAY = "#95A5A6"
MED_GRAY = "#7F8C8D"
VERY_LIGHT = "#BDC3C7"

2 — MCDA: Weighted Scoring#

Multi-criteria decision analysis scores each candidate control across several dimensions — risk reduction, implementation cost, time to deploy, operational complexity, regulatory alignment — then combines them into a single weighted score:

\[\text{score}_i = \sum_k w_k \cdot s_{ik}\]

where \(w_k\) are the criteria (swing) weights (summing to 1) and \(s_{ik}\) is the normalized score of control \(i\) on criterion \(k\).

Normalization matters. Raw scores on different scales (dollars, months, percentage risk reduction) must be mapped to a common range, typically \([0, 1]\), before weighting. Min-max normalization is the simplest approach:

\[s_{ik} = \frac{x_{ik} - \min_i x_{ik}}{\max_i x_{ik} - \min_i x_{ik}}\]

The choice of normalization method can change rankings — this is not a bug but a feature, because it forces you to decide what “better” means on each dimension.

We evaluate six candidate controls against four criteria. Three of the criteria are “inverse” (lower raw value is better), so we name them with an _inv suffix and flip them before scoring.

Criterion

Weight

Direction

risk_reduction

0.40

higher is better

cost_inv

0.30

lower cost is better

time_inv

0.15

shorter implementation is better

complexity_inv

0.15

simpler is better

The weights encode stakeholder priorities. Making weights explicit and visible is the primary benefit of MCDA — it transforms “I just think MFA is more important” into a testable, debatable parameter.

Tip: A practical approach to weight elicitation: ask each stakeholder to distribute 100 points across the criteria, after showing them each criterion’s range across the candidates (e.g. cost from $30K to $200K). The points value a swing from worst to best on that range, not the criterion in the abstract. Average the distributions, then show individual allocations alongside the average. Disagreements about weights are often more informative than the final ranking because they show where the leadership team lacks alignment on strategic priorities.

controls = [
    "MFA Rollout",
    "EDR Deployment",
    "Network Segmentation",
    "Security Awareness",
    "Patch Automation",
    "DLP Gateway",
]

# `_inv` columns: lower raw value is better; normalize_mcda() flips them.
raw = pd.DataFrame(
    {
        "risk_reduction": [0.35, 0.50, 0.45, 0.15, 0.40, 0.25],
        "cost_inv":       [50, 200, 180, 30, 90, 150],       # $K
        "time_inv":       [2, 6, 8, 1, 3, 5],                # months
        "complexity_inv": [2, 4, 5, 1, 3, 4],                # 1-5 scale
    },
    index=controls,
)
raw
risk_reduction cost_inv time_inv complexity_inv
MFA Rollout 0.35 50 2 2
EDR Deployment 0.50 200 6 4
Network Segmentation 0.45 180 8 5
Security Awareness 0.15 30 1 1
Patch Automation 0.40 90 3 3
DLP Gateway 0.25 150 5 4
def normalize_mcda(df):
    """Min-max normalise to [0, 1]; '_inv' columns are flipped."""
    normed = pd.DataFrame(index=df.index)
    for col in df.columns:
        lo, hi = df[col].min(), df[col].max()
        span = hi - lo if hi != lo else 1.0
        if col.endswith("_inv"):
            normed[col] = (hi - df[col]) / span
        else:
            normed[col] = (df[col] - lo) / span
    return normed


normed = normalize_mcda(raw)
normed.round(3)
risk_reduction cost_inv time_inv complexity_inv
MFA Rollout 0.571 0.882 0.857 0.75
EDR Deployment 1.000 0.000 0.286 0.25
Network Segmentation 0.857 0.118 0.000 0.00
Security Awareness 0.000 1.000 1.000 1.00
Patch Automation 0.714 0.647 0.714 0.50
DLP Gateway 0.286 0.294 0.429 0.25
weights = np.array([0.40, 0.30, 0.15, 0.15])
assert np.isclose(weights.sum(), 1.0), "Weights must sum to 1"

normed["weighted_score"] = normed.values @ weights
normed["rank"] = normed["weighted_score"].rank(ascending=False).astype(int)
normed.sort_values("rank")
risk_reduction cost_inv time_inv complexity_inv weighted_score rank
MFA Rollout 0.571429 0.882353 0.857143 0.75 0.734349 1
Patch Automation 0.714286 0.647059 0.714286 0.50 0.661975 2
Security Awareness 0.000000 1.000000 1.000000 1.00 0.600000 3
EDR Deployment 1.000000 0.000000 0.285714 0.25 0.480357 4
Network Segmentation 0.857143 0.117647 0.000000 0.00 0.378151 5
DLP Gateway 0.285714 0.294118 0.428571 0.25 0.304307 6
sorted_df = normed.sort_values('weighted_score', ascending=True)
fig, ax = plt.subplots(figsize=(9, 5))
ax.barh(sorted_df.index, sorted_df['weighted_score'], color=DARK_BG, edgecolor='white')
for i, (idx_name, row) in enumerate(sorted_df.iterrows()):
    ax.text(row['weighted_score'] + 0.005, i, f'{row["weighted_score"]:.2f}',
            va='center', fontsize=9, color=PRIMARY)
ax.set_xlabel('Weighted MCDA Score')
ax.set_title('Control Ranking by MCDA Score')
plt.tight_layout()
plt.show()
../_images/e360419e70943a7afd715f89d322f613eb05d7753a9d2844cd70c7de382b45d0.png

3 — Sensitivity Analysis#

Any ranking that depends on weights should be stress-tested. Sensitivity analysis asks: how much would a weight need to change before a control’s position flips? These critical thresholds are called flip points, and they reveal whether a ranking is robust or fragile.

We vary the cost_inv weight from 0.10 to 0.60, redistributing the remaining weight among the other three criteria in the same proportions they had originally, and record every cost weight at which two controls swap ranks.

The result for this portfolio is reassuring at the top and busy underneath: MFA Rollout holds rank 1 across the entire 0.10-0.60 sweep, so the headline recommendation is robust to how much the organization cares about cost. All the churn is in ranks 2-6 — Patch Automation, Security Awareness, EDR, and the rest trade places as the cost weight climbs (the flip points printed below).

In practice, presenting sensitivity results to stakeholders is as valuable as the ranking itself. When a decision-maker sees that the #2 slot swings with the cost weight while #1 does not, the conversation shifts from “which control is best?” to “how much do we actually value cost?” — which is the right question to be asking.

base_weights = np.array([0.40, 0.30, 0.15, 0.15])
cost_idx = 1  # cost_inv is the second criterion

cost_range = np.arange(0.10, 0.61, 0.01)
normed_vals = normalize_mcda(raw).values

# Remaining weight (1 - cost weight) is split across the other criteria
# in their original proportions.
others_frac = base_weights.copy()
others_frac[cost_idx] = 0.0
others_frac = others_frac / others_frac.sum()

W = others_frac[None, :] * (1.0 - cost_range)[:, None]  # (n_weights x 4)
W[:, cost_idx] = cost_range

scores_matrix = normed_vals @ W.T                       # (n_controls x n_weights)
# rank 1 = best, via double argsort
ranks_matrix = (-scores_matrix).argsort(axis=0).argsort(axis=0) + 1
rank_history = {c: ranks_matrix[i].tolist() for i, c in enumerate(controls)}

fig, ax = plt.subplots(figsize=(8, 4))
# Six series: palette colors x line styles, so each line stays identifiable without a rainbow.
styles = [(DARK_BG, "-"), (ACCENT, "-"), (MED_GRAY, "-"), (DARK_BG, "--"), (ACCENT, "--"), (MED_GRAY, "--")]
for c, (col, ls) in zip(controls, styles):
    ax.plot(cost_range, rank_history[c], color=col, ls=ls, lw=2, label=c)
ax.set_xlabel("Weight on cost_inv")
ax.set_ylabel("Rank (1 = best)")
ax.set_title("Sensitivity: Rank vs. Cost Weight")
ax.invert_yaxis()
ax.legend(loc="center left", bbox_to_anchor=(1, 0.5), fontsize=8)
plt.tight_layout()
plt.show()
../_images/a1be44818b2d2ddbb7386959fe1be4c215ef00968035207bcef75575b515cfb3.png
print("Flip points (cost_inv weight where a rank swap occurs):")
prev_ranks = {c: rank_history[c][0] for c in controls}
for step, cw in enumerate(cost_range[1:], 1):
    for c in controls:
        if rank_history[c][step] != prev_ranks[c]:
            print(f"  cost_w={cw:.2f}: {c} moved from rank {prev_ranks[c]} to {rank_history[c][step]}")
    prev_ranks = {c: rank_history[c][step] for c in controls}

rank1_stable = [c for c in controls if all(r == 1 for r in rank_history[c])]
print()
if rank1_stable:
    print(f"Rank-1 control stable across ALL tested weights: {', '.join(rank1_stable)}")
else:
    print("No control holds rank 1 across the full sweep.")
Flip points (cost_inv weight where a rank swap occurs):
  cost_w=0.21: EDR Deployment moved from rank 3 to 4
  cost_w=0.21: Security Awareness moved from rank 4 to 3
  cost_w=0.41: Security Awareness moved from rank 3 to 2
  cost_w=0.41: Patch Automation moved from rank 2 to 3
  cost_w=0.51: Network Segmentation moved from rank 5 to 6
  cost_w=0.51: DLP Gateway moved from rank 6 to 5
  cost_w=0.57: EDR Deployment moved from rank 4 to 5
  cost_w=0.57: DLP Gateway moved from rank 5 to 4

Rank-1 control stable across ALL tested weights: MFA Rollout

4 — Greedy ROI Selection#

When the goal is simply to maximize risk reduction under a budget constraint, a greedy approach is fast and intuitive: compute each control’s risk reduction per dollar (ROI), sort descending, and select controls in order until the budget is exhausted. Total risk reduction is the sum of the selected controls’ reductions: this assumes each control removes a separate slice of risk, so their effects add up.

This works well when controls are roughly independent and similarly sized. It is the same logic behind the fractional knapsack solution, and it produces optimal results when items can be partially selected. For indivisible controls (you either deploy MFA or you don’t), greedy is a good heuristic but not guaranteed optimal, and the gap is not always small: on this notebook’s data greedy lands 10–33% below the true optimum at three of the ten budgets in Section 7.

Ranking by risk reduction per dollar says nothing about how well the chosen set uses the budget. Greedy can spend part of the budget on the controls with the best ratios and leave an amount too small to buy any remaining control. A different set, with a lower average ratio, may use more of the budget and reduce more risk in total. This is the 0/1 knapsack problem: the best set is not always built from the best ratios.

In fact, the $300K instance in this notebook is exactly such a counterexample. Greedy spends its budget on the top bang-per-buck items and lands about 10% below the true integer optimum, which does better by skipping one of them to afford EDR. We prove that with brute force in Section 6.

We use select_controls_by_roi from decision-security with a $300K budget.

deltas = raw["risk_reduction"].values
costs = raw["cost_inv"].values * 1_000
budget = 300_000

chosen_idx, total_cost, total_delta = select_controls_by_roi(deltas, costs, budget)

roi_table = pd.DataFrame(
    {
        "control": [controls[i] for i in chosen_idx],
        "risk_reduction": [deltas[i] for i in chosen_idx],
        "cost_K": [costs[i] / 1_000 for i in chosen_idx],
        "roi": [deltas[i] / costs[i] * 1_000 for i in chosen_idx],
    }
)
roi_table["cumulative_cost_K"] = roi_table["cost_K"].cumsum()
roi_table["cumulative_reduction"] = roi_table["risk_reduction"].cumsum()

idle = budget - total_cost
unpicked_costs = [costs[i] for i in range(len(controls)) if i not in chosen_idx]
cheapest_unpicked = min(unpicked_costs) if unpicked_costs else 0

print(f"Budget: ${budget:,.0f}")
print(f"Spent:  ${total_cost:,.0f}  |  Risk reduced: {total_delta:.2f}")
print(f"Idle:   ${idle:,.0f}  |  Cheapest unpicked control: ${cheapest_unpicked:,.0f}")
roi_table
Budget: $300,000
Spent:  $170,000  |  Risk reduced: 0.90
Idle:   $130,000  |  Cheapest unpicked control: $150,000
control risk_reduction cost_K roi cumulative_cost_K cumulative_reduction
0 MFA Rollout 0.35 50.0 0.007000 50.0 0.35
1 Security Awareness 0.15 30.0 0.005000 80.0 0.50
2 Patch Automation 0.40 90.0 0.004444 170.0 0.90

5 — MCDA vs. ROI: When They Disagree#

MCDA and greedy ROI can produce different selections because they optimize different objectives. When they diverge, it usually means the top-ROI control scores poorly on a dimension that MCDA weights care about (e.g., it is fast and cheap but creates heavy operational burden).

Aspect

MCDA

Greedy ROI

Objective

Highest overall score across criteria

Maximum risk reduction per dollar

Budget awareness

Not inherently budget-constrained

Stops at the budget cap

Criteria breadth

Considers time, complexity, etc.

Only risk reduction and cost

Explainability

Transparent weights; auditable

Simple ratio; easy to verify

Neither method is universally better:

  • MCDA when stakeholders care about more than just cost and risk — for example, implementation speed, operational complexity, or regulatory alignment.

  • ROI when the decision is purely financial and the single objective is clear: maximize risk reduction subject to a hard budget.

  • Both together as a sanity check: if a control ranks highly on MCDA but is absent from the ROI selection, investigate why (often it is expensive relative to its risk reduction alone).

A useful heuristic: if the MCDA ranking and the ROI ranking agree on the top three controls, you have a robust ranking regardless of method, though not necessarily the best portfolio: here both put MFA, Patch Automation and Security Awareness on top, yet the best $300K portfolio swaps Patch Automation for EDR (Section 6). If they disagree significantly, the disagreement itself is a valuable output because it tells you exactly which trade-offs need executive attention rather than analyst judgment.

6 — Linear Programming: Relaxed 0-1 Knapsack#

For larger portfolios or tighter constraints, linear programming (LP) provides a rigorous alternative to greedy heuristics. The control selection problem maps directly to a 0-1 knapsack formulation:

\[\max \sum_i v_i \, x_i \quad \text{subject to} \quad \sum_i c_i \, x_i \leq B, \quad x_i \in \{0, 1\}\]

where \(v_i\) is the risk reduction of control \(i\), \(c_i\) is its cost, \(B\) is the total budget, and \(x_i\) indicates whether control \(i\) is selected.

The LP relaxation allows \(x_i\) to take any value in \([0, 1]\), which provides an upper bound on the achievable risk reduction and is solvable in milliseconds. Comparing the relaxed solution to the greedy integer solution gives an upper bound on how much optimality you are leaving on the table: the gap mixes relaxation slack with real greedy loss, as the next cell shows.

You can also add side constraints beyond budget: maximum headcount for implementation, minimum coverage of certain risk categories, or mutual exclusivity (choose cloud WAF or on-prem WAF, not both). Each constraint is an additional row in the LP matrix.

scipy.optimize.linprog minimizes, so we negate risk reduction to maximize it.

from itertools import product

n = len(controls)

# LP relaxation: upper bound
c_obj = -deltas
A_ub = [costs]
b_ub = [budget]
bounds = [(0, 1)] * n
result = linprog(c_obj, A_ub=A_ub, b_ub=b_ub, bounds=bounds, method="highs")
assert result.success, f"LP failed: {result.message}"
lp_reduction = -result.fun
lp_allocation = pd.Series(result.x, index=controls).round(4)

# Integer optimum by brute force over all 2**n subsets; ties go to the cheapest.
best_delta, best_cost, best_mask = 0.0, np.inf, np.zeros(n, dtype=int)
for combo in product([0, 1], repeat=n):
    mask = np.array(combo)
    c = (mask * costs).sum()
    if c <= budget:
        d = (mask * deltas).sum()
        if d > best_delta or (d == best_delta and c < best_cost):
            best_delta, best_cost, best_mask = d, c, mask
int_selection = [controls[i] for i in range(n) if best_mask[i]]
int_cost = (best_mask * costs).sum()

print("Total risk reduction:")
print(f"  Greedy ROI:                  {total_delta:.3f}")
print(f"  Integer optimum:             {best_delta:.3f}   <- {' + '.join(int_selection)} at ${int_cost:,.0f}")
print(f"  LP relaxation (upper bound): {lp_reduction:.3f}")
print()
print(f"  Greedy loss (integer - greedy): {best_delta - total_delta:.3f}")
print(f"  LP slack (LP - integer):        {lp_reduction - best_delta:.3f}")
print()
print("LP allocation (fractional = split control):")
lp_allocation
Total risk reduction:
  Greedy ROI:                  0.900
  Integer optimum:             1.000   <- MFA Rollout + EDR Deployment + Security Awareness at $280,000
  LP relaxation (upper bound): 1.225

  Greedy loss (integer - greedy): 0.100
  LP slack (LP - integer):        0.225

LP allocation (fractional = split control):
MFA Rollout             1.0000
EDR Deployment          0.0000
Network Segmentation    0.7222
Security Awareness      1.0000
Patch Automation        1.0000
DLP Gateway             0.0000
dtype: float64

The three numbers tell a layered story about “the cost of indivisibility”:

  • Greedy ROI = 0.90. Fast and intuitive, but it greedily spends on the best bang-per-buck items and then cannot afford anything else — leaving risk reduction on the table.

  • Integer optimum = 1.00. Brute force over all 2⁶ subsets finds that MFA + EDR + Security Awareness ($280K) does strictly better (three subsets tie at 1.00; we report the cheapest). The 0.10 gap between greedy and this optimum is real suboptimality — exactly the knapsack trap described earlier.

  • LP relaxation = 1.225. Allowing fractional controls gives an upper bound no integer selection can reach. The 0.225 gap between the integer optimum and the LP bound is relaxation slack: it exists only because the LP is allowed to buy a fraction of Network Segmentation (0.72; it ties with EDR on ratio) to spend the budget exactly. It is not reduction any real portfolio can capture.

So the LP bound does not measure how much the heuristic leaves on the table — that is the greedy-to-integer gap (0.10). The LP tells you the integer optimum cannot exceed 1.225, which is useful for knowing when to stop searching, not for grading greedy.

7 — Efficient Frontier: Budget vs. Risk Reduction#

Varying the budget constraint and solving repeatedly traces an efficient frontier: the maximum achievable risk reduction at each budget level. Below, the frontier is the brute-force integer optimum at each budget; the LP curve bounds it from above. This curve typically shows diminishing returns — the first $500K buys substantially more risk reduction than the next $500K.

The frontier answers two practical questions:

  • Where does the curve flatten? Beyond this budget, you are buying increasingly marginal improvements. This is the natural “enough” point for budget negotiations.

  • Are we on the frontier? If your current portfolio sits below the curve, you are leaving risk reduction on the table with the same spend. Reallocation beats new budget.

The frontier also answers questions that boards and CFOs routinely ask: “What is the marginal value of an additional $100K in security budget?” Having a data-backed answer changes the conversation from “trust me” to “here is the trade-off curve.”

budgets = np.arange(50_000, 500_001, 50_000)
lp_reductions = []
greedy_reductions = []
int_reductions = []

subsets = np.array(list(product([0, 1], repeat=n)))
subset_costs = subsets @ costs
subset_deltas = subsets @ deltas

for b in budgets:
    res = linprog(c_obj, A_ub=[costs], b_ub=[b], bounds=bounds, method="highs")
    lp_reductions.append(-res.fun if res.success else np.nan)

    int_reductions.append(subset_deltas[subset_costs <= b].max())

    _, _, g_delta = select_controls_by_roi(deltas, costs, b)
    greedy_reductions.append(g_delta)

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(budgets / 1_000, int_reductions, "^-", color=DARK_BG, lw=2, label="Integer optimum (frontier)")
ax.plot(budgets / 1_000, lp_reductions, "o--", color=LIGHT_GRAY, label="LP relaxation (upper bound)")
ax.plot(budgets / 1_000, greedy_reductions, "s-", color=ACCENT, label="Greedy ROI")
ax.set_xlabel("Budget ($K)")
ax.set_ylabel("Total risk reduction")
ax.set_title("Efficient Frontier: Budget vs. Risk Reduction")
ax.legend()
plt.tight_layout()
plt.show()

frontier = pd.DataFrame({
    "budget_K": budgets // 1_000,
    "greedy": greedy_reductions,
    "integer": int_reductions,
    "lp": lp_reductions,
})
frontier["greedy_loss"] = (frontier["integer"] - frontier["greedy"]).clip(lower=0)
frontier["lp_slack"] = (frontier["lp"] - frontier["integer"]).clip(lower=0)
frontier["greedy_gap_pct"] = 100 * frontier["greedy_loss"] / frontier["integer"]
print(frontier.round(3).to_string(index=False))
../_images/948e5ae7ea95d5d79c0b2b74a77c91506b35939f74652d175f51abc21b585741.png
 budget_K  greedy  integer    lp  greedy_loss  lp_slack  greedy_gap_pct
       50    0.35     0.35 0.350         0.00     0.000           0.000
      100    0.50     0.50 0.589         0.00     0.089           0.000
      150    0.50     0.75 0.811         0.25     0.061          33.333
      200    0.90     0.90 0.975         0.00     0.075           0.000
      250    0.90     0.90 1.100         0.00     0.200           0.000
      300    0.90     1.00 1.225         0.10     0.225          10.000
      350    1.35     1.35 1.350         0.00     0.000           0.000
      400    1.40     1.40 1.475         0.00     0.075           0.000
      450    1.40     1.40 1.600         0.00     0.200           0.000
      500    1.40     1.60 1.725         0.20     0.125          12.500
roi_vals = deltas / costs
order = np.argsort(roi_vals)[::-1]
bar_colors = [ACCENT if i in chosen_idx else VERY_LIGHT for i in order]

fig, ax = plt.subplots(figsize=(9, 5))
ax.barh([controls[i] for i in order],
        [roi_vals[i] for i in order],
        color=bar_colors, edgecolor='white')
ax.set_xlabel('ROI (Risk Reduction per Dollar)')
ax.set_title(f'Greedy Selection (red = selected, budget ${budget:,.0f})')
plt.tight_layout()
plt.show()
../_images/c8ba99ae9bf4002ebc8e23b2602f39c11c009fe1c74dec7a78c304eba924e260.png
fig, ax = plt.subplots(figsize=(9, 5))
x_pos = np.arange(n)
width = 0.35

greedy_alloc = np.zeros(n)
for ci in chosen_idx:
    greedy_alloc[ci] = 1.0

ax.bar(x_pos - width/2, result.x, width, label='LP (relaxed)', color=DARK_BG, alpha=0.8)
ax.bar(x_pos + width/2, greedy_alloc, width, label='Greedy', color=ACCENT, alpha=0.8)
ax.set_xticks(x_pos)
ax.set_xticklabels(controls, rotation=30, ha='right')
ax.set_ylabel('Allocation (0 = skip, 1 = select)')
ax.set_title('LP vs Greedy: Control Allocation')
ax.legend()
plt.tight_layout()
plt.show()
../_images/e8621b4b4488f06c06433143f0e248a931194ee1e0006cd534ad1c33b9401bb1.png

Notice how the LP curve bends at first: each extra $50K buys less as the budget moves down the ratio list. This is the classic diminishing-returns pattern. With only six controls it does not flatten in this range: from $200K it runs straight, because EDR and Network Segmentation share the same ratio. The integer frontier rises in uneven steps, since whole controls arrive in lumps. The LP-relaxation curve sits above the greedy curve because it is an upper bound. That gap mixes relaxation slack (fractional controls the LP is allowed to buy) with real greedy loss, and the mix changes with budget: at $300K it is mostly slack (0.225 vs 0.10), at $150K and $500K mostly greedy loss (0.25 and 0.20). To measure true heuristic suboptimality, compare greedy against the integer optimum, as in Section 6.

8 — Pitfalls#

Weights must sum to 1. If they do not, the weighted score is on an arbitrary scale and comparisons across analyses become meaningless. Always normalize.

Incomparable scales. Cost in dollars and complexity on a 1–5 ordinal scale cannot be summed directly. Normalization (min-max, z-score, or rank-based) is mandatory before applying weights. The choice of normalization method itself affects results, so document it.

Criteria washing. Adding redundant or highly correlated criteria inflates the importance of the dimension they represent. If “cost” and “TCO” are both included at equal weight, cost effectively gets double the influence. Each criterion should capture a distinct concern, so audit your criteria for independence before you set weights.

Ignoring interactions. Both MCDA and greedy selection treat controls independently. In reality, deploying network segmentation changes the marginal value of east-west detection. Portfolio-aware methods (covered in later chapters) handle this, but for a first pass, independence is a reasonable simplifying assumption.

Optimizing the wrong objective. A technically optimal portfolio that ignores political feasibility, implementation sequencing, or organizational capacity will not survive contact with reality. Treat optimization output as a starting point for discussion, it is almost never the final answer.

Note: Both MCDA and LP are deterministic methods applied to uncertain inputs. The risk-reduction estimates, cost figures, and criteria scores feeding these models are themselves uncertain. Robust approaches — running the optimization across many Monte Carlo samples of the inputs — are covered in later chapters. For now, the key insight is that structured prioritization, even with imperfect inputs, consistently outperforms unstructured intuition.