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:
Belief perseverance – clinging to the initial hypothesis after the evidence has shifted
Confirmation bias – selectively weighting evidence that supports what you already believe
Sunk cost – continuing a failed investigation because of what you have already spent
Resulting – evaluating decision quality by outcome rather than process
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])}.")
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.")
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.")
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()
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:
List all plausible hypotheses (not just the leading one)
List all available evidence
For each hypothesis-evidence pair, mark whether the evidence is Consistent (C), Inconsistent (I), or Neutral (N)
Drop evidence that is consistent with every hypothesis (it has no diagnostic value)
Count inconsistencies per hypothesis
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()
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.