Confirmation Bias & Belief Perseverance in Incident Response#

Part 0 introduced behavioral biases at a surface level – anchoring warps estimates, overconfidence inflates certainty, framing flips preferences. This notebook goes deeper.

Incident response is where cognitive biases do the most operational damage. An IR analyst forms a hypothesis in the first ten minutes of triage. From that point forward, every piece of evidence is filtered through that hypothesis. Confirming evidence gets amplified. Disconfirming evidence gets explained away.

This notebook covers four traps and one countermeasure:

  1. Belief perseverance – clinging to the initial hypothesis after the evidence has shifted

  2. Confirmation bias – selectively weighting evidence that supports what you already believe

  3. Sunk cost – continuing a failed investigation because of what you have already spent

  4. Resulting – evaluating decision quality by outcome rather than process

  5. Structured debiasing – Analysis of Competing Hypotheses as a countermeasure

Setup#

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from decision_security.synth import make_rng

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"

1. Belief Perseverance#

An IR analyst gets paged at 2 AM. The SIEM alert shows repeated failed login attempts from an employee’s account – off-hours, escalating privilege requests. The analyst forms an immediate hypothesis: insider threat.

Then evidence starts arriving. Some of it supports the insider hypothesis. Some of it points strongly toward compromised credentials – an external attacker using stolen creds. A rational Bayesian updater adjusts their belief with each new piece of evidence, regardless of direction. An analyst experiencing belief perseverance does not.

Annie Duke (Thinking in Bets, 2018) describes the pattern: once a belief is lodged, it becomes difficult to dislodge. It takes on a life of its own, leading us to notice and seek out evidence confirming the belief and to ignore or actively discredit information that contradicts it.

The mechanism is simple: after forming a hypothesis, people unconsciously raise the bar for disconfirming evidence while lowering it for confirming evidence. The initial hypothesis becomes sticky because people have committed to it.

We model this with sequential Bayesian updates. Each piece of evidence has a true likelihood ratio (how strongly it favors insider vs external). The rational analyst applies these ratios faithfully. The belief-perseverant analyst dampens updates that contradict the initial hypothesis.

# --- Belief Perseverance: Rational vs Belief-Perseverant Analyst ---

# (description, likelihood ratio for insider): LR > 1 favors insider, LR < 1 favors external attacker
evidence = [
    ("Successful logins from employee's home IP",        3.0),
    ("Access attempts on HR database (employee's dept)", 2.5),
    ("Credential found on dark-web paste (3 days old)",  0.15),
    ("USB device plugged in at employee's workstation",  2.0),
    ("Login from TOR exit node using same credentials",  0.10),
]

prior_insider = 0.70  # analyst's gut read after initial triage

# Rational Bayesian updater
rational_trajectory = [prior_insider]
odds = prior_insider / (1 - prior_insider)

for desc, lr in evidence:
    odds *= lr
    posterior = odds / (1 + odds)
    rational_trajectory.append(posterior)

# Belief-perseverant analyst: every update is rescaled in log space, biased_LR = LR ** k.
# The constants are illustrative, not measured: disconfirming evidence keeps 40% of its
# true log-weight, confirming evidence gets a 20% boost. An illustrative asymmetry --
# yet enough to flip the final verdict.
DAMPEN = 0.4
AMPLIFY = 1.2

biased_trajectory = [prior_insider]
biased_odds = prior_insider / (1 - prior_insider)

for desc, lr in evidence:
    log_lr = np.log(lr)
    if lr < 1:
        adjusted_log_lr = log_lr * DAMPEN
    else:
        adjusted_log_lr = log_lr * AMPLIFY
    biased_odds *= np.exp(adjusted_log_lr)
    posterior = biased_odds / (1 + biased_odds)
    biased_trajectory.append(posterior)

labels = ["Prior"] + [desc[:50] for desc, _ in evidence]

print(f"{'Step':<55} {'Rational':>10} {'Biased':>10}")
print("-" * 77)
for i, label in enumerate(labels):
    print(f"{label:<55} {rational_trajectory[i]:>10.3f} {biased_trajectory[i]:>10.3f}")
Step                                                      Rational     Biased
-----------------------------------------------------------------------------
Prior                                                        0.700      0.700
Successful logins from employee's home IP                    0.875      0.897
Access attempts on HR database (employee's dept)             0.946      0.963
Credential found on dark-web paste (3 days old)              0.724      0.925
USB device plugged in at employee's workstation              0.840      0.966
Login from TOR exit node using same credentials              0.344      0.918
# --- Plot: Rational vs Belief-Perseverant Trajectories ---

fig, ax = plt.subplots(figsize=(9, 5))

steps = range(len(rational_trajectory))
ax.plot(steps, rational_trajectory, "o-", color=DARK_BG, lw=2.5,
        markersize=8, label="Rational Bayesian", zorder=3)
ax.plot(steps, biased_trajectory, "s--", color=ACCENT, lw=2.5,
        markersize=8, label="Belief-perseverant analyst", zorder=3)

ax.axhline(0.5, color=LIGHT_GRAY, ls=":", lw=1, alpha=0.7)
ax.text(4.6, 0.52, "50/50", color=LIGHT_GRAY, fontsize=8, ha="right")

evidence_labels_short = [
    "Successful logins\n(home IP)",
    "HR database\naccess",
    "Creds on\ndark web",
    "USB device\nplugged in",
    "TOR exit\nnode login",
]
for i, label in enumerate(evidence_labels_short):
    ax.annotate(label, xy=(i + 1, rational_trajectory[i + 1]),
                xytext=(0, -30), textcoords="offset points",
                fontsize=7, ha="center", color=DARK_BG, alpha=0.7)

ax.set_xticks(steps)
ax.set_xticklabels(["Prior"] + [f"E{i+1}" for i in range(len(evidence))])
ax.set_xlabel("Evidence sequence")
ax.set_ylabel("P(insider threat)")
ax.set_title("Belief Perseverance: How Initial Hypotheses Resist Updating")
ax.set_ylim(-0.02, 1.02)
ax.legend(loc="upper right")
plt.tight_layout()
plt.show()

def verdict(p):
    return "an insider" if p > 0.5 else "an external attacker"

print("\nAfter all evidence:")
print(f"  Rational analyst:  P(insider) = {rational_trajectory[-1]:.3f}")
print(f"  Biased analyst:    P(insider) = {biased_trajectory[-1]:.3f}")
print(f"  Gap:               {biased_trajectory[-1] - rational_trajectory[-1]:+.3f}")
print(f"\nThe rational analyst favors {verdict(rational_trajectory[-1])}; "
      f"the biased analyst favors {verdict(biased_trajectory[-1])}.")
../_images/26b4f2af2b42a44b99d05d81e4d06aa6e1f288340848e3b490bfe0324a3e7227.png
After all evidence:
  Rational analyst:  P(insider) = 0.344
  Biased analyst:    P(insider) = 0.918
  Gap:               +0.574

The rational analyst favors an external attacker; the biased analyst favors an insider.

2. Confirmation Bias: Selective Evidence Weighting#

The biased analyst does not just under-weight contradictory evidence – they actively seek evidence that confirms their hypothesis and avoid evidence that might refute it. Here we model only how evidence is weighted – the same LR ** k mechanism as Section 1, with a stronger asymmetry.

In practice, this manifests as selective attention to likelihood ratios. When evidence supports the working hypothesis, the analyst inflates its diagnostic value (“this is exactly what we’d expect to see”). When evidence contradicts the hypothesis, the analyst discounts it (“that could mean anything”).

Scenario: An IR team is investigating a data exfiltration alert. The DLP system flagged 2 GB of data leaving the network to an external IP. The lead analyst’s initial hypothesis is malicious insider.

We construct eight pieces of evidence – five that support the insider hypothesis and three that support an alternative explanation (misconfigured DLP rule generating a false positive). Each item has a true likelihood ratio. The confirmation-biased analyst rescales every ratio in log space – biased_LR = LR ** k: k = 1.5 for hypothesis-consistent evidence (which sharpens its diagnostic pull) and k = 0.5 for inconsistent evidence (a square-root shrink that drags the ratio back toward 1.0, the neutral value).

# --- Confirmation Bias: Selective Evidence Weighting ---

# LR > 1 favors "malicious insider"; LR < 1 favors "misconfigured DLP"
evidence_items = pd.DataFrame({
    "Evidence": [
        "Employee accessed sensitive files outside role",
        "Transfer occurred after business hours",
        "Employee recently passed over for promotion",
        "Destination IP linked to personal cloud storage",
        "DLP rule was updated 48 hours before alert",
        "Same alert pattern seen on 3 other endpoints",
        "Transfer matches scheduled backup job timing",
        "No encryption on transferred data (backup uses TLS)",
    ],
    "True LR": [3.0, 1.8, 2.0, 2.5, 0.20, 0.15, 0.25, 1.5],
    "Direction": [
        "Insider", "Insider", "Insider", "Insider",
        "DLP misconfig", "DLP misconfig", "DLP misconfig", "Insider",
    ],
})

# biased_LR = LR ** k. Hypothesis = insider, so LR >= 1 is confirming.
# K_CONFIRM > 1 pushes confirming LRs away from 1.0; K_DISCONFIRM = 0.5 is a
# square-root shrink toward 1.0: LR = 0.2 -> 0.2 ** 0.5 = 0.45.
K_CONFIRM = 1.5
K_DISCONFIRM = 0.5

biased_lrs = []
for _, row in evidence_items.iterrows():
    lr = row["True LR"]
    k = K_CONFIRM if lr >= 1.0 else K_DISCONFIRM
    biased_lrs.append(lr ** k)

evidence_items["Biased LR"] = biased_lrs

print(evidence_items[["Evidence", "True LR", "Biased LR", "Direction"]].to_string(index=False))
                                           Evidence  True LR  Biased LR     Direction
     Employee accessed sensitive files outside role     3.00   5.196152       Insider
             Transfer occurred after business hours     1.80   2.414953       Insider
        Employee recently passed over for promotion     2.00   2.828427       Insider
    Destination IP linked to personal cloud storage     2.50   3.952847       Insider
         DLP rule was updated 48 hours before alert     0.20   0.447214 DLP misconfig
       Same alert pattern seen on 3 other endpoints     0.15   0.387298 DLP misconfig
       Transfer matches scheduled backup job timing     0.25   0.500000 DLP misconfig
No encryption on transferred data (backup uses TLS)     1.50   1.837117       Insider
# --- Plot: Posterior divergence between rational and biased analyst ---

prior_p = 0.50

def compute_trajectory(prior, likelihood_ratios):
    """Apply sequential LR updates, return list of posteriors."""
    trajectory = [prior]
    odds = prior / (1 - prior)
    for lr in likelihood_ratios:
        odds *= lr
        trajectory.append(odds / (1 + odds))
    return trajectory

rational_traj = compute_trajectory(prior_p, evidence_items["True LR"].values)
biased_traj = compute_trajectory(prior_p, evidence_items["Biased LR"].values)

fig, ax = plt.subplots(figsize=(10, 5))

steps = range(len(rational_traj))
ax.plot(steps, rational_traj, "o-", color=DARK_BG, lw=2.5, markersize=7,
        label="Rational analyst", zorder=3)
ax.plot(steps, biased_traj, "s--", color=ACCENT, lw=2.5, markersize=7,
        label="Confirmation-biased analyst", zorder=3)

ax.axhline(0.5, color=LIGHT_GRAY, ls=":", lw=1, alpha=0.7)

ax.fill_between(steps, rational_traj, biased_traj, alpha=0.12, color=ACCENT)

ax.set_xticks(steps)
xlabels = ["Prior"] + [f"E{i+1}" for i in range(len(evidence_items))]
ax.set_xticklabels(xlabels)
ax.set_xlabel("Evidence item")
ax.set_ylabel("P(malicious insider)")
ax.set_title("Confirmation Bias: How Selective Weighting Distorts Posteriors")
ax.set_ylim(-0.02, 1.02)
ax.legend(loc="lower right")
plt.tight_layout()
plt.show()

print("\nFinal P(insider):")
print(f"  Rational analyst:  {rational_traj[-1]:.3f}")
print(f"  Biased analyst:    {biased_traj[-1]:.3f}")
if (rational_traj[-1] > 0.5) != (biased_traj[-1] > 0.5):
    print(f"\nThe same {len(evidence_items)} pieces of evidence lead to opposite conclusions.")
../_images/6ff63eecb8af809b1a7ba3a3c82ddcf05d93297a8e026c2dda92a0abb1d3f2b8.png
Final P(insider):
  Rational analyst:  0.233
  Biased analyst:    0.957

The same 8 pieces of evidence lead to opposite conclusions.

3. Sunk Cost in Incident Containment#

An organization has spent three weeks and $200K investigating what they believe is a nation-state APT. The CISO has briefed the board. The IR team has deployed custom detection signatures. The threat intel team has built a profile of the suspected actor.

Then the forensics team finds evidence that this is commodity ransomware. The C2 infrastructure matches a known ransomware-as-a-service operator. The probability that this is actually an APT has dropped to 20%.

The rational decision depends only on future costs and probabilities. The $200K already spent is gone regardless of what happens next. But the sunk cost fallacy pulls the team toward continuing the APT investigation (“we’ve already invested so much, we can’t pivot now”).

# --- Sunk Cost Decision Analysis ---

sunk_cost = 200_000  # already spent -- irrelevant to the forward decision

p_apt = 0.20
p_ransomware = 0.80

# Option A: Continue APT investigation, $50K/week for ~3 more weeks
#   - If it IS an APT: successful containment (avoids ~$300K in potential damage)
#   - If it's ransomware: wasted weeks, ransomware spreads, +$500K in damages
apt_weekly_cost = 50_000
apt_weeks = 3
apt_investigation_cost = apt_weekly_cost * apt_weeks

apt_if_correct = apt_investigation_cost
apt_if_wrong = apt_investigation_cost + 500_000

ev_continue_apt = p_apt * apt_if_correct + p_ransomware * apt_if_wrong

# Option B: Pivot to ransomware playbook, $20K one-time
#   - If it IS ransomware: quick containment (saves $500K in spread damage)
#   - If it's actually APT: miss the APT, $300K in additional damage later
pivot_cost = 20_000

ransomware_if_correct = pivot_cost
ransomware_if_wrong = pivot_cost + 300_000

ev_pivot = p_ransomware * ransomware_if_correct + p_apt * ransomware_if_wrong

print("=== Forward-Looking Decision Analysis ===")
print(f"(Sunk cost of ${sunk_cost:,.0f} is excluded -- it's gone either way)\n")
print("Option A: Continue APT investigation")
print(f"  If APT (p={p_apt:.0%}):       ${apt_if_correct:>10,.0f}")
print(f"  If ransomware (p={p_ransomware:.0%}): ${apt_if_wrong:>10,.0f}")
print(f"  Expected future cost:       ${ev_continue_apt:>10,.0f}\n")
print("Option B: Pivot to ransomware playbook")
print(f"  If ransomware (p={p_ransomware:.0%}): ${ransomware_if_correct:>10,.0f}")
print(f"  If APT (p={p_apt:.0%}):       ${ransomware_if_wrong:>10,.0f}")
print(f"  Expected future cost:       ${ev_pivot:>10,.0f}\n")
print(f"Rational choice: {'Pivot' if ev_pivot < ev_continue_apt else 'Continue APT'}")
print(f"Savings from pivoting: ${ev_continue_apt - ev_pivot:,.0f}")
=== Forward-Looking Decision Analysis ===
(Sunk cost of $200,000 is excluded -- it's gone either way)

Option A: Continue APT investigation
  If APT (p=20%):       $   150,000
  If ransomware (p=80%): $   650,000
  Expected future cost:       $   550,000

Option B: Pivot to ransomware playbook
  If ransomware (p=80%): $    20,000
  If APT (p=20%):       $   320,000
  Expected future cost:       $    80,000

Rational choice: Pivot
Savings from pivoting: $470,000
# --- Bar chart: Sunk Cost vs Rational Decision ---

fig, ax = plt.subplots(figsize=(8, 5))

labels = ["Continue APT", "Pivot to ransomware"]
rational_vals = [ev_continue_apt, ev_pivot]
sunk_vals = [sunk_cost + v for v in rational_vals]  # same constant added to both paths

x = np.arange(len(labels))
width = 0.35

bars1 = ax.bar(x - width/2, [v/1000 for v in sunk_vals], width,
               label=f"Including \\${sunk_cost/1000:.0f}K already spent (same on both paths)",
               color=LIGHT_GRAY, edgecolor="white")
bars2 = ax.bar(x + width/2, [v/1000 for v in rational_vals], width,
               label="Future costs only (rational)", color=DARK_BG, edgecolor="white")

best = int(np.argmin(rational_vals))
ax.bar(x[best] + width/2, rational_vals[best]/1000, width,
       color=ACCENT, edgecolor="white", label="Optimal decision")

for bar in bars1:
    ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 8,
            f"\\${bar.get_height():.0f}K", ha="center", fontsize=8, color=LIGHT_GRAY)
for bar in bars2:
    ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 8,
            f"\\${bar.get_height():.0f}K", ha="center", fontsize=8, color=PRIMARY)

ax.set_ylabel("Expected cost (\\$K)")
ax.set_title("Sunk Cost Fallacy: Past Spend Should Not Affect Future Decisions")
ax.set_xticks(x)
ax.set_xticklabels(labels, fontsize=9)
ax.legend(loc="upper right", fontsize=8)
ax.set_ylim(0, max(sunk_vals)/1000 * 1.2)

plt.tight_layout()
plt.show()

print(f"The ${sunk_cost:,.0f} already spent is identical in both paths -- it cannot")
print("influence the decision. Only future expected costs matter.")
../_images/1075e07688876696df03a71cad876afb0ce92f07e3b5b19c903ea88aa0938c6e.png
The $200,000 already spent is identical in both paths -- it cannot
influence the decision. Only future expected costs matter.

4. The Resulting Trap#

Annie Duke’s concept of “resulting” describes the tendency to evaluate decision quality by looking at outcomes rather than process. In security, this is pervasive:

  • The person who skipped the patch window and nothing happened gets praised for “not disrupting operations.”

  • The person who correctly escalated an alert that turned out to be a false positive gets criticized for “wasting the team’s time.”

The problem is that good decisions can produce bad outcomes (the firewall rule was correct but the attacker found a different path) and bad decisions can produce good outcomes (we left the vulnerability unpatched and the attacker never found it).

The right question is, knowing what you did at that time, was the decision right?

We simulate 100 security decisions per run and average over 500 runs so the picture is not one lucky draw. Each decision has a known probability of a good outcome. A “good process” decision-maker takes the expected-value-favorable action (so the realized success probability is max(p, 1 - p)); a “bad process” one takes the dominated action (min(p, 1 - p)).

# --- The Resulting Trap: Decision Quality vs Outcome Quality ---

n_decisions = 100
n_seeds = 500

counts = np.zeros((2, 2))
misclass_rates = []

for s in range(n_seeds):
    r = make_rng(1000 + s)

    # e.g. "if we patch now, 85% chance the system stays stable"
    true_prob_good_outcome = r.uniform(0.3, 0.95, size=n_decisions)

    good_process = r.random(n_decisions) < 0.70

    # Good process takes the better side, max(p, 1 - p); bad process the dominated one.
    effective_prob = np.where(
        good_process,
        np.maximum(true_prob_good_outcome, 1 - true_prob_good_outcome),
        np.minimum(true_prob_good_outcome, 1 - true_prob_good_outcome),
    )

    good_outcome = r.random(n_decisions) < effective_prob

    #                    Good Outcome    Bad Outcome
    # Good Process:     Deserved win    Unlucky loss
    # Bad Process:      Lucky win       Deserved loss
    counts[0, 0] += np.sum(good_process & good_outcome)
    counts[0, 1] += np.sum(good_process & ~good_outcome)
    counts[1, 0] += np.sum(~good_process & good_outcome)
    counts[1, 1] += np.sum(~good_process & ~good_outcome)

    misclass_rates.append(
        (np.sum(good_process & ~good_outcome) + np.sum(~good_process & good_outcome))
        / n_decisions
    )

mean_counts = counts / n_seeds
misclass_pct = float(np.mean(misclass_rates))

deserved_win, unlucky_loss = mean_counts[0]
lucky_win, deserved_loss = mean_counts[1]

confusion = pd.DataFrame(
    np.round(mean_counts).astype(int),
    index=["Good process", "Bad process"],
    columns=["Good outcome", "Bad outcome"],
)

print(f"=== Decision Quality x Outcome Quality (mean of {n_seeds} runs, per 100 decisions) ===\n")
print(confusion.to_string())

print("\n--- Resulting Misclassification ---")
print(f"Good decisions punished (unlucky loss):   {unlucky_loss:.1f}")
print(f"Bad decisions rewarded (lucky win):        {lucky_win:.1f}")
print(f"Mean misclassified by resulting:            {misclass_pct:.0%} of decisions")
=== Decision Quality x Outcome Quality (mean of 500 runs, per 100 decisions) ===

              Good outcome  Bad outcome
Good process            48           22
Bad process              9           21

--- Resulting Misclassification ---
Good decisions punished (unlucky loss):   21.9
Bad decisions rewarded (lucky win):        9.2
Mean misclassified by resulting:            31% of decisions
# --- Heatmap: The Resulting Matrix ---

fig, ax = plt.subplots(figsize=(6, 4.5))

matrix = confusion.values
from matplotlib.colors import LinearSegmentedColormap
counts_cmap = LinearSegmentedColormap.from_list("counts", ["#F4F6F6", DARK_BG])  # one hue: more cases, darker
im = ax.imshow(matrix, cmap=counts_cmap, aspect="auto", vmin=0, vmax=matrix.max())
ax.set_xticks([0.5], minor=True)
ax.set_yticks([0.5], minor=True)
ax.grid(which="minor", color="white", linewidth=3)
ax.tick_params(which="minor", length=0)

cell_labels = [
    ["Deserved\nwin", "Unlucky\nloss"],
    ["Lucky\nwin", "Deserved\nloss"],
]

for i in range(2):
    for j in range(2):
        color = "white" if matrix[i, j] > matrix.max() * 0.6 else PRIMARY
        ax.text(j, i, f"{cell_labels[i][j]}\n{matrix[i, j]}",
                ha="center", va="center", fontsize=11, fontweight="bold",
                color=color)

ax.set_xticks([0, 1])
ax.set_xticklabels(["Good outcome", "Bad outcome"], fontsize=10)
ax.set_yticks([0, 1])
ax.set_yticklabels(["Good process", "Bad process"], fontsize=10)
ax.set_title("The Resulting Trap: Outcome Is Not a Reliable Signal of Decision Quality")

# Highlight the misclassified quadrants
for j, i in [(1, 0), (0, 1)]:  # unlucky loss, lucky win
    rect = plt.Rectangle((j - 0.46, i - 0.46), 0.92, 0.92, linewidth=2.5,
                          edgecolor=ACCENT, facecolor="none", linestyle="--", zorder=3)
    ax.add_patch(rect)

ax.set_xlabel(f"Dashed = misclassified by resulting ({misclass_pct:.0%})", fontsize=9, color=ACCENT)

plt.tight_layout()
plt.show()
../_images/34ba0f3f06715a22a0417c2fc87b794e524aab13a19afacdec7baf1bb8b50230.png

5. Debiasing: Analysis of Competing Hypotheses (ACH)#

Structured Analytic Techniques were promoted within the CIA’s Directorate of Intelligence to counteract cognitive biases, confirmation bias among them, in intelligence analysis. One of the best-known techniques is Analysis of Competing Hypotheses (ACH), developed by Richards Heuer at the CIA in the 1970s and described in Psychology of Intelligence Analysis (1999).

The method forces the analyst to:

  1. List all plausible hypotheses (not just the leading one)

  2. List all available evidence

  3. For each hypothesis-evidence pair, mark whether the evidence is Consistent (C), Inconsistent (I), or Neutral (N)

  4. Drop evidence that is consistent with every hypothesis (it has no diagnostic value)

  5. Count inconsistencies per hypothesis

  6. Reject hypotheses with the most inconsistencies

The key point of the method may feel counterintuitive at first: ACH focuses on disconfirming evidence rather than confirming evidence. A hypothesis survives not because lots of evidence supports it, but because little evidence contradicts it. This is designed to counteract confirmation bias, which amplifies confirming evidence and ignores contradictions (the experimental evidence that it succeeds is mixed: Dhami, Belton & Mandel, 2019).

The matrix below also prints a Consistencies (C) row, but that row is diagnostic only. The ACH verdict is decided solely on the Inconsistencies (I) count; consistencies never break a tie.

Scenario: Same data exfiltration alert. Three competing hypotheses.

# --- Analysis of Competing Hypotheses (ACH) Matrix ---

hypotheses = ["Malicious insider", "External attacker\n(compromised creds)", "Misconfigured\nDLP rule"]
hyp_short = ["Insider", "External", "DLP misconfig"]

evidence_ach = [
    "Employee accessed files outside role",
    "Transfer occurred after business hours",
    "Credential found on dark-web paste",
    "Same alert on 3 other endpoints",
    "Transfer matches backup job timing",
    "No TLS on transfer (backups use TLS)",
]

# 1 = Consistent, 0 = Neutral, -1 = Inconsistent; rows = evidence, columns = hypotheses
ach_matrix = np.array([
    # Insider  External  DLP misconfig
    [  1,       0,        -1],    # Files outside role
    [  1,       1,         0],    # After hours
    [ -1,       1,         0],    # Dark-web paste
    [ -1,       0,         1],    # Same alert 3 endpoints
    [  0,      -1,         1],    # Matches backup timing
    [  1,       1,        -1],    # No TLS
])

label_map = {1: "C", 0: "N", -1: "I"}

ach_display = pd.DataFrame(
    [[label_map[v] for v in row] for row in ach_matrix],
    index=evidence_ach,
    columns=hyp_short,
)

inconsistencies = (ach_matrix == -1).sum(axis=0)
consistencies = (ach_matrix == 1).sum(axis=0)

ach_display.loc["---"] = ["---"] * 3
ach_display.loc["Inconsistencies (I)"] = inconsistencies
ach_display.loc["Consistencies (C)"] = consistencies

print("=== ACH Matrix ===\n")
print(ach_display.to_string())

print("\n--- ACH Verdict ---")
survivor_idx = np.argmin(inconsistencies)
print(f"Fewest inconsistencies: {hyp_short[survivor_idx]} ({inconsistencies[survivor_idx]} I's)")
print("ACH does not confirm a hypothesis -- it rejects the ones with the most contradictory evidence.")

print("\nNote: a separate exercise from Section 2 (6 items, 3 hypotheses); numbers are not directly comparable.")
if rational_traj[-1] < 0.5 and survivor_idx != 0:
    survivor = hypotheses[survivor_idx].replace("\n", " ")
    print(f"Both point away from the insider hypothesis (the rational Bayesian P(insider) fell to ~{rational_traj[-1]:.2f}).")
    print("Section 2's alternative was a misconfigured DLP rule; ACH, with a third hypothesis and the")
    print(f"dark-web credential added, keeps {survivor} as the survivor.")
=== ACH Matrix ===

                                       Insider External DLP misconfig
Employee accessed files outside role         C        N             I
Transfer occurred after business hours       C        C             N
Credential found on dark-web paste           I        C             N
Same alert on 3 other endpoints              I        N             C
Transfer matches backup job timing           N        I             C
No TLS on transfer (backups use TLS)         C        C             I
---                                        ---      ---           ---
Inconsistencies (I)                          2        1             2
Consistencies (C)                            3        3             2

--- ACH Verdict ---
Fewest inconsistencies: External (1 I's)
ACH does not confirm a hypothesis -- it rejects the ones with the most contradictory evidence.

Note: a separate exercise from Section 2 (6 items, 3 hypotheses); numbers are not directly comparable.
Both point away from the insider hypothesis (the rational Bayesian P(insider) fell to ~0.23).
Section 2's alternative was a misconfigured DLP rule; ACH, with a third hypothesis and the
dark-web credential added, keeps External attacker (compromised creds) as the survivor.
# --- ACH Heatmap Visualization ---

fig, ax = plt.subplots(figsize=(7, 5))

# ACH works by disconfirmation, so only Inconsistent gets a strong color.
from matplotlib.colors import ListedColormap
C_COLOR, N_COLOR = "#D6DBDF", "#F4F6F6"
cmap = ListedColormap([ACCENT, N_COLOR, C_COLOR])   # I=-1, N=0, C=1

im = ax.imshow(ach_matrix, cmap=cmap, aspect="auto", vmin=-1, vmax=1)
ax.set_xticks(np.arange(-0.5, ach_matrix.shape[1]), minor=True)
ax.set_yticks(np.arange(-0.5, ach_matrix.shape[0]), minor=True)
ax.grid(which="minor", color="white", linewidth=2)
ax.tick_params(which="minor", length=0)

for i in range(ach_matrix.shape[0]):
    for j in range(ach_matrix.shape[1]):
        val = ach_matrix[i, j]
        ax.text(j, i, label_map[val], ha="center", va="center",
                fontsize=13, fontweight="bold", color="white" if val == -1 else PRIMARY)

# Inconsistency counts go under each hypothesis name, where they don't cover a cell.
ax.set_xticks(range(len(hyp_short)))
ax.set_xticklabels([f"{h}\n{n} inconsistent" for h, n in zip(hyp_short, inconsistencies)], fontsize=10)
ax.set_yticks(range(len(evidence_ach)))
ax.set_yticklabels(evidence_ach, fontsize=9)
ax.set_title("Analysis of Competing Hypotheses (ACH)")

from matplotlib.patches import Patch
legend_elements = [
    Patch(facecolor=ACCENT, label="Inconsistent (I)"),
    Patch(facecolor=N_COLOR, edgecolor=LIGHT_GRAY, label="Neutral (N)"),
    Patch(facecolor=C_COLOR, label="Consistent (C)"),
]
ax.legend(handles=legend_elements, loc="upper center", fontsize=8,
          bbox_to_anchor=(0.5, -0.16), ncol=3, frameon=False)

plt.tight_layout()
plt.show()
../_images/1d963525631292cf76acc553208bca2b8282bf8e0b1c81092958bcad8bae3b17.png

6. Pitfalls#

Belief perseverance can strengthen with public commitment. Once “this is an insider threat” has been reported internally, the psychological cost of reversing that judgment goes up. People are no longer just updating a belief – they are admitting they were wrong in front of their team. IR post-mortems should evaluate evidence interpretation, not just outcomes, and organizations should reward analysts who update their hypotheses in response to new evidence.

Confirmation bias is invisible to the person experiencing it. The biased analyst genuinely believes they are being objective. They are not lying about the evidence – they are seeing the evidence differently because their hypothesis is filtering their perception. This is why structured techniques like ACH exist: they externalize the reasoning process and make selective attention visible.

Sunk costs are amplified by risk-seeking in losses. Kahneman and Tversky’s prospect theory predicts that people become risk-seeking when facing losses. An IR team that has already spent $200K on an APT investigation is in the loss domain – pivoting means realizing that loss. Continuing the APT investigation is a gamble that might justify the spend. The sunk cost fallacy and the loss-domain gamble reinforce each other, making the pivot even harder than the raw numbers suggest.

“Resulting” punishes good process and rewards lucky negligence. If your organization evaluates security decisions by whether an incident occurred, you are building incentives for luck-seeking behavior. The CISO who signs off on deferring a critical patch and gets lucky looks better than the CISO who holds to the patch schedule and takes a brief outage. Over time, this selects for risk-taking disguised as efficiency.


Next: Part 2.2 explores normalization of deviance – how repeated exceptions that cause no immediate harm get redefined as acceptable.