Inspection Game

A ranger who patrols just 40% of sites can still stop poaching - but only if the penalty is harsh enough. Soften it and the same patrols let crime pay.

Level: Intermediate

game-theoryreinforcementmonitoringdeterrence

  • Feedback Loops: poacher adaptation (reinforcement)
  • Probes: catch_rate, ranger_coverage, poacher_payoff
simulation.py

Inspection game: does crime pay?

A ranger can only patrol m of n sites each day, yet must deter several adaptive poachers. Getting caught costs a poacher a heavy penalty; a successful raid earns a reward. The twist: you don't need to watch every site. Poaching pays only when the expected reward beats the expected penalty — when coverage m/n falls below reward / (reward + penalty).

The knob is penalty. With coverage fixed at 40%, a lenient penalty leaves poaching profitable (payoff > 0); a harsh one deters it (payoff < 0).


import random
from tys import probe, progress


def simulate(cfg: dict):
    """Simulate repeated patrols and adaptive poachers."""

    import simpy

    env = simpy.Environment()

    n_sites = cfg["num_sites"]       # total locations that could be patrolled
    patrols = cfg["patrols"]         # how many sites the ranger covers each day
    num_poachers = cfg["num_poachers"]
    reward = cfg["reward"]           # gain for an uncaught poacher
    penalty = cfg["penalty"]         # cost if caught red-handed
    lr = cfg.get("learning_rate", 0.1)   # update weight for reinforcement
    eps = cfg.get("epsilon", 0.1)        # exploration probability
    sim_time = cfg["sim_time"]

    rng = random.Random(cfg.get("seed", 123))

Each poacher tracks estimated value per site.

    q_values = [[0.0 for _ in range(n_sites)] for _ in range(num_poachers)]

    catch_count = 0
    attempts = 0
    total_payoff = 0.0

    done = env.event()

One step represents a day of patrols and poaching.

    def day():
        nonlocal catch_count, attempts, total_payoff
        for t in range(sim_time):
            patrol_sites = rng.sample(range(n_sites), k=patrols)
            probe("ranger_coverage", env.now, patrols / n_sites)

            for p in range(num_poachers):
                q = q_values[p]
                if rng.random() < eps:
                    site = rng.randrange(n_sites)
                else:
                    best = max(q)
                    best_sites = [i for i, v in enumerate(q) if v == best]
                    site = rng.choice(best_sites)

                attempts += 1
                if site in patrol_sites:
                    catch_count += 1
                    payoff = -penalty
                else:
                    payoff = reward

simple reinforcement update

                q[site] = (1 - lr) * q[site] + lr * payoff
                total_payoff += payoff

            catch_rate = catch_count / attempts
            avg_payoff = total_payoff / attempts
            probe("catch_rate", env.now, catch_rate)
            probe("poacher_payoff", env.now, avg_payoff)
            progress(100 * (t + 1) / sim_time)
            yield env.timeout(1)

        done.succeed({
            "catch_rate": catch_rate,
            "avg_payoff": avg_payoff,
            "poaching_pays": avg_payoff > 0,
        })

    env.process(day())
    env.run(until=done)
    return done.value


def requirements():
    return {
        "external": ["simpy==4.1.1"],
    }
Poaching Pays.yaml
# lenient.yaml — the default: the penalty (5) is only half the reward (10),
# so even with 40% coverage poaching pays (poacher_payoff stays positive).
num_sites: 5
patrols: 2
num_poachers: 3
reward: 10
penalty: 5
learning_rate: 0.2
epsilon: 0.1
sim_time: 100
seed: 123
Charts (Poaching Pays)

ranger_coverage

Samples100 @ 0.00–99.00
Valuesmin 0.40, mean 0.40, median 0.40, max 0.40, σ 0.00

catch_rate

Samples100 @ 0.00–99.00
Valuesmin 0.30, mean 0.38, median 0.38, max 0.43, σ 0.03

poacher_payoff

Samples100 @ 0.00–99.00
Valuesmin 3.55, mean 4.33, median 4.38, max 5.56, σ 0.39
Final Results (Poaching Pays)
MetricValue
catch_rate0.37
avg_payoff4.40
poaching_paystrue
Deterred.yaml
# deterrent.yaml — the contrast: identical except penalty (5 -> 50). The same
# 40% coverage now deters poaching because the expected penalty outweighs the
# reward (poacher_payoff goes negative).
num_sites: 5
patrols: 2
num_poachers: 3
reward: 10
penalty: 50
learning_rate: 0.2
epsilon: 0.1
sim_time: 100
seed: 123
Charts (Deterred)

ranger_coverage

Samples100 @ 0.00–99.00
Valuesmin 0.40, mean 0.40, median 0.40, max 0.40, σ 0.00

catch_rate

Samples100 @ 0.00–99.00
Valuesmin 0.25, mean 0.35, median 0.37, max 0.44, σ 0.04

poacher_payoff

Samples100 @ 0.00–99.00
Valuesmin -16.67, mean -11.25, median -12.29, max -5.29, σ 2.69
Final Results (Deterred)
MetricValue
catch_rate0.41
avg_payoff-14.60
poaching_paysfalse
FAQ
When does poaching pay?
When the expected reward beats the expected penalty: poaching is profitable while coverage (patrols/num_sites) stays below reward/(reward+penalty). At reward 10 and penalty 5 that threshold is 0.67, well above the 0.4 coverage, so poaching pays; at penalty 50 it drops to 0.17, below 0.4, so poaching is deterred.
Why does only the penalty change between the two runs?
Coverage (40%) and every other parameter are frozen, so the poacher_payoff line crossing from positive to negative is caused by the penalty alone - severity substituting for inspection rate.
How do poachers choose a site?
Each poacher keeps a Q-value per site and updates it after every raid (q = (1-lr)*q + lr*payoff), mostly picking its best-valued site but exploring at random with probability epsilon.
What is the real-world lesson?
Random audits, tax inspections, or transit fare checks deter cheating without checking everyone - a low inspection probability paired with a steep penalty can be enough.