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 is a weighted sum of normalized criteria over criteria with weights (where ):
The ranking is the ordering of the . Sensitivity analysis perturbs 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:
where is the difference between a candidate’s baseline rank and its perturbed rank, and is the number of candidates. A near 1.0 means the ranking barely moved; a low or negative 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 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 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.
Prerequisites
- Python packages:
numpy,pandas, andscipy(forscipy.stats.spearmanrand the Dirichlet sampler). Install withpip install numpy pandas scipy. - A criteria matrix
Xof 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
w0summing 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.05–0.20 |
Absolute nudge applied to one weight, then re-normalized |
n_samples |
int, 1000–10000 |
Monte Carlo draws over the simplex |
concentration |
float, > 0 |
Dirichlet concentration; higher = samples cluster near w0 |
unstable_rank_std |
float, 1.0–3.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.
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:
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")
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.
Verification
- Baseline recovery: with zero perturbation,
rank_from_weights(X, w0)reproduces the ranking the scoring stage emitted. - Spearman distribution: summarize the Monte Carlo
spearmanarray — a median above ~0.9 with a tight spread indicates a defensible ranking. - Instability flags: confirm
unstablesites are genuinely volatile by inspecting theirrank_std.
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 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.
Related
- Ranking & Shortlisting Candidate Sites — the ranking this analysis stress-tests before it is finalized.
- Building Weighted Site Suitability Scores — defines the weights whose robustness is measured here.
- Normalizing Mixed-Scale Site Attributes for Scoring — prerequisite normalization so sensitivity reflects weights, not scales.
← Back to Ranking & Shortlisting Candidate Sites