Sensitivity Analysis for Site Ranking Weights

This page solves one task: measuring how much a site ranking changes when the criteria weights change, so you can tell a real-estate committee whether the shortlist is a robust conclusion or an artifact of one analyst’s weighting guess.

Every composite suitability score depends on weights — how much population matters versus income, competition, or rent. Those weights are judgment calls, and a ranking that reshuffles violently when a weight moves a few points is indefensible. Sensitivity analysis quantifies that fragility. It is the validation step that should run before any output from ranking and shortlisting candidate sites is presented as final, and it directly tests the weighting scheme built in building weighted site suitability scores.

What we measure

The score for candidate ii is a weighted sum of normalized criteria xicx_{ic} over criteria cc with weights wcw_c (where cwc=1\sum_c w_c = 1):

Si(w)=cwcxicS_i(\mathbf{w}) = \sum_{c} w_c \, x_{ic}

The ranking is the ordering of the SiS_i. Sensitivity analysis perturbs w\mathbf{w} and asks how much the ordering moves. The natural metric for “how much did an ordering move” is Spearman’s rank correlation between the baseline ranking and the perturbed one:

ρs=16idi2n(n21)\rho_s = 1 - \frac{6 \sum_i d_i^2}{n(n^2 - 1)}

where did_i is the difference between a candidate’s baseline rank and its perturbed rank, and nn is the number of candidates. A ρs\rho_s near 1.0 means the ranking barely moved; a low or negative ρs\rho_s means the weights are driving the conclusion, not the data. We run two perturbation schemes: one-at-a-time (OAT), which nudges a single weight to attribute fragility to a specific criterion, and Monte Carlo over the weight simplex, which samples the full space of plausible weightings to get a distribution of ρs\rho_s and to flag individual sites whose rank is unstable.

The two schemes answer different questions and are complementary rather than redundant. OAT is diagnostic: by moving one weight while holding the rest fixed, it attributes any instability to a specific criterion, which tells the analyst which judgment call the conclusion actually hinges on. Its weakness is that it explores only the axes of the weight space, never the interior, so it can miss instability that appears only when several weights move together. Monte Carlo fills that gap by sampling the whole simplex — every point where the weights are non-negative and sum to one — producing an honest distribution of outcomes rather than a handful of one-dimensional slices. The constraint cwc=1\sum_c w_c = 1 is what makes the weight space a simplex rather than a box, and sampling it correctly is why the implementation draws from a Dirichlet distribution rather than perturbing weights independently and hoping they still sum to one.

A single global Spearman statistic can hide a locally fragile shortlist: the overall ordering may be stable while two specific sites near the top-N boundary swap places under almost any reweighting. That is why the analysis tracks per-candidate rank volatility in addition to the aggregate correlation — a rank reversal inside the shortlist changes which sites get funded, whereas a reversal among the tail changes nothing anyone acts on.

Which weight the ranking is actually sensitive to Perturbing each criterion weight by plus and minus ten per cent and re-ranking shows the reachable population weight moving the top ten by up to seven places, competition by four, rent by three and co-tenancy by one. The criterion the committee argues about most is not the one the ranking depends on most. Sensitivity is a property of the ranking, not of the weight no change weight −10% weight +10% reachable population up to 7 places moved competitive saturation up to 4 places rent up to 3 places co-tenancy up to 1 place Bar length is the largest rank change anywhere in the top ten. Co-tenancy could be dropped from the model without changing a single decision — worth knowing before the next meeting spends an hour on it.

Prerequisites

  • Python packages: numpy, pandas, and scipy (for scipy.stats.spearmanr and the Dirichlet sampler). Install with pip install numpy pandas scipy.
  • A criteria matrix X of shape (n_candidates, n_criteria) with each column already normalized to a common scale — the job of normalizing mixed-scale site attributes for scoring. Sensitivity analysis on un-normalized criteria measures scale artifacts, not weight sensitivity.
  • A baseline weight vector w0 summing to 1.
  • A perturbation budget: the OAT step size (e.g. ±0.10) and the Monte Carlo sample count (a few thousand draws).

Configuration and execution parameters

Parameter Value / type Notes
w0 array, sums to 1 Baseline criteria weights
oat_delta float, 0.050.20 Absolute nudge applied to one weight, then re-normalized
n_samples int, 100010000 Monte Carlo draws over the simplex
concentration float, > 0 Dirichlet concentration; higher = samples cluster near w0
unstable_rank_std float, 1.03.0 A site whose rank std exceeds this is flagged unstable
reversal_top_n int Only reversals within the top-N shortlist are material

The Dirichlet concentration encodes how much weight uncertainty you actually believe in: a large value samples tightly around the baseline (you trust the weights), a small value explores the whole simplex (you do not). Sampling the full simplex uniformly is the conservative, stress-test choice.

Annotated implementation

The routine computes the baseline ranking, runs OAT perturbations per criterion, then a Monte Carlo sweep, returning the Spearman distribution and per-site rank stability.

python
import numpy as np
import pandas as pd
from scipy.stats import spearmanr


def rank_from_weights(X: np.ndarray, w: np.ndarray) -> np.ndarray:
    """Return ordinal ranks (1 = best) for scores S = X @ w."""
    scores = X @ w
    order = np.argsort(-scores, kind="mergesort")   # stable, best first
    ranks = np.empty(len(scores), dtype=int)
    ranks[order] = np.arange(1, len(scores) + 1)
    return ranks


def oat_sensitivity(X: np.ndarray, w0: np.ndarray,
                    delta: float = 0.10) -> pd.DataFrame:
    """Nudge each weight by +/-delta, renormalize, measure Spearman vs baseline."""
    base = rank_from_weights(X, w0)
    rows = []
    for c in range(len(w0)):
        for sign in (+1, -1):
            w = w0.copy()
            w[c] = max(w[c] + sign * delta, 0.0)
            w = w / w.sum()                          # keep the simplex constraint
            rho, _ = spearmanr(base, rank_from_weights(X, w))
            rows.append({"criterion": c, "sign": sign, "spearman": rho})
    return pd.DataFrame(rows)


def monte_carlo_sensitivity(X: np.ndarray, w0: np.ndarray,
                            n_samples: int = 5000,
                            concentration: float = 20.0,
                            seed: int = 42) -> dict:
    """Sample weights ~ Dirichlet(concentration * w0); track rank stability."""
    rng = np.random.default_rng(seed)
    base = rank_from_weights(X, w0)
    n_cand = X.shape[0]
    alpha = concentration * w0                        # center mass on baseline
    rank_matrix = np.empty((n_samples, n_cand), dtype=int)
    spearmans = np.empty(n_samples)

    for s in range(n_samples):
        w = rng.dirichlet(alpha)                      # sums to 1 by construction
        r = rank_from_weights(X, w)
        rank_matrix[s] = r
        spearmans[s], _ = spearmanr(base, r)

    return {
        "baseline_rank": base,
        "spearman": spearmans,                        # distribution over draws
        "rank_std": rank_matrix.std(axis=0),          # per-candidate volatility
        "rank_mean": rank_matrix.mean(axis=0),
    }

Rank-reversal detection then flags the sites that actually matter — pairs that swap order within the shortlisted top-N, where a reversal changes a real decision rather than shuffling also-rans:

python
def top_n_reversals(base: np.ndarray, mc: dict, top_n: int = 15,
                    unstable_std: float = 2.0) -> pd.DataFrame:
    """Flag shortlisted sites whose rank is volatile across the weight sweep."""
    in_shortlist = base <= top_n
    flags = pd.DataFrame({
        "candidate": np.arange(len(base)),
        "baseline_rank": base,
        "rank_mean": mc["rank_mean"].round(1),
        "rank_std": mc["rank_std"].round(2),
        "in_shortlist": in_shortlist,
    })
    flags["unstable"] = in_shortlist & (mc["rank_std"] > unstable_std)
    return flags.sort_values("baseline_rank")
Which sites appear in the top five whatever the weights Across a baseline and four defensible weight scenarios, two sites appear in every top five, one appears in four, and three appear in only one or two. The sites that survive every scenario are the robust core of the shortlist; the rest are scenario-dependent and should be presented as such. The robust core: sites that win under every defensible weighting baseline reach-led cost-led defensive regressed appears S-114 5 / 5 S-042 5 / 5 S-259 4 / 5 S-088 2 / 5 S-176 1 / 5 S-330 1 / 5 Present the 5/5 sites as the recommendation and the rest as conditional on a weighting the committee can still choose.

Failure modes and debugging

Symptom Cause Fix
Spearman near 1.0 for every perturbation Criteria highly correlated, so weights barely matter Real robustness, or redundant criteria — check pairwise correlation of X.
Wildly unstable ranks Criteria columns on different scales Normalize X before analysis; raw scales masquerade as weight sensitivity.
Dirichlet draws hug the baseline concentration too high Lower it to stress-test; use a small value to sample the full simplex.
A perturbed weight goes negative OAT delta exceeds the baseline weight The max(..., 0) clamp and renormalization handle it; interpret as dropping the criterion.
Reversals reported but immaterial Counting swaps outside the shortlist Restrict reversal detection to the top-N, as top_n_reversals does.

The most common misread is treating a high Spearman as unambiguous good news. If two criteria are nearly collinear, reallocating weight between them changes little — the ranking is robust for the wrong reason. Inspect the criteria correlation structure before concluding the ranking is sound.

Where two candidates swap, expressed as a weight the committee can judge As the rent weight rises from 0.05 to 0.30, the score of the reach-heavy site falls from 0.83 to 0.71 while the low-rent site rises from 0.74 to 0.80. The lines cross at a rent weight of 0.19, so the ranking question becomes whether occupancy cost deserves more or less than a fifth of the model. Turn "which site wins?" into "how much does rent matter?" 0.85 0.80 0.75 0.70 0.05 0.11 0.17 0.24 0.30 weight given to rent composite score they swap at 0.19 S-114 · reach-heavy, expensive S-042 · cheaper, smaller catchment A committee cannot adjudicate a hundredth of a composite score, but it can answer whether rent is worth a fifth of the model — and that answer settles the ranking.

Verification

  1. Baseline recovery: with zero perturbation, rank_from_weights(X, w0) reproduces the ranking the scoring stage emitted.
  2. Spearman distribution: summarize the Monte Carlo spearman array — a median above ~0.9 with a tight spread indicates a defensible ranking.
  3. Instability flags: confirm unstable sites are genuinely volatile by inspecting their rank_std.
python
mc = monte_carlo_sensitivity(X, w0, n_samples=5000)
print(f"Spearman median: {np.median(mc['spearman']):.3f}")
print(f"Spearman 5th pct: {np.percentile(mc['spearman'], 5):.3f}")
flags = top_n_reversals(mc["baseline_rank"], mc, top_n=15)
print(flags[flags["unstable"]])

Report the 5th percentile of the Spearman distribution, not just the median: the committee needs the worst plausible case, not the average one. A ranking whose median ρs\rho_s is 0.95 but whose 5th percentile is 0.6 has a tail of weightings that reshuffle the shortlist, and any site flagged unstable should carry that caveat into the final report rather than being presented as a confident recommendation.

Frequently Asked Questions

How big a perturbation should the analysis apply?

Big enough to cover the disagreement that actually exists in the room, which is usually five to ten points on a weight rather than a fraction of one. A perturbation smaller than the uncertainty in the weights tests nothing, and one much larger tests a model nobody proposed. If two people would defend 0.30 and 0.40 for the same criterion, that spread is the perturbation, and running the ranking at both ends answers the question directly instead of approximating it.

Should sensitivity be run before or after the shortlist is cut?

Before, because the cut is the decision the sensitivity is supposed to inform. Running it afterwards produces an interesting appendix; running it first changes how many sites are carried, whether the near-tie band is flagged, and which criteria the committee is asked to rule on. The output of the analysis belongs on the cover page of the pack, not in a technical annex nobody opens.

What does it mean when no weight changes the ranking?

Usually that one criterion dominates the composite, or that the candidates differ so much that any reasonable weighting orders them the same way. The first is worth investigating — a criterion with a wide numeric spread can dominate regardless of its weight, which is a normalization problem rather than a weighting one. The second is genuinely good news, and it should be stated plainly: a ranking that is invariant to the weights is far easier to defend than one that is merely defensible under the weights chosen.

Can the analysis be automated as a gate?

Yes, and it makes a good one. Compute the rank correlation between the baseline ranking and each perturbed ranking, and fail the run when the top of the list falls below a threshold you have agreed in advance. That converts a subjective worry — “is this ranking fragile?” — into a number attached to every scored run, and it catches the case where a routine data refresh has quietly moved the model onto a knife-edge.

← Back to Ranking & Shortlisting Candidate Sites