File size: 11,642 Bytes
aaef24a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8eccedb
aaef24a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8eccedb
aaef24a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f006b5f
aaef24a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# /// script
# requires-python = ">=3.13"
# dependencies = [
#     "altair",
#     "asimpy",
#     "marimo",
#     "polars==1.24.0",
# ]
# ///

import marimo

__generated_with = "0.20.4"
app = marimo.App(width="medium")


@app.cell(hide_code=True)
def _():
    import marimo as mo
    import math
    import random

    import altair as alt
    import polars as pl

    from asimpy import Environment, Process

    return Environment, Process, alt, math, mo, pl, random


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    # Braess's Paradox

    ## *Adding a Road Makes Traffic Worse*

    A city has two routes from source $S$ to destination $T$:

    - **Top route** $S \to A \to T$: link $SA$ is congestion-dependent; link $AT$ has a fixed travel time.
    - **Bottom route** $S \to B \to T$: link $SB$ has a fixed travel time; link $BT$ is congestion-dependent.

    The network is symmetric. A city planner proposes adding a new shortcut link $A \to B$ with near-zero travel time, creating a third route $S \to A \to B \to T$. To her surprise, adding the shortcut makes everyone's travel time longer at the selfish-routing Nash equilibrium.

    ### Without the shortcut

    Both routes are symmetric. In equilibrium, traffic splits evenly. If $N/2$ drivers use each route and the congested links have delay $\alpha \cdot n$ (where $n$ is the number of cars):

    $$t_{\text{top}} = \frac{N}{2}\alpha + c = t_{\text{bottom}}$$

    ### With the shortcut $A \to B$

    Each driver thinks, "Link $AB$ is free; I can use $SA$, slip across to $B$, then take $BT$ instead of the slow constant link $AT$." All $N$ drivers make this choice. The Nash equilibrium has everyone on $S \to A \to B \to T$:

    $$t_{\text{shortcut}} = N\alpha + \varepsilon + N\alpha = 2N\alpha + \varepsilon$$

    Since $2N\alpha > \frac{N}{2}\alpha + c$ for typical parameters, travel times *increase* after the road is added. This is the paradox: individually rational decisions produce a collectively worse outcome. The ratio of Nash equilibrium cost to the socially optimal cost is called the *[price of anarchy](https://en.wikipedia.org/wiki/Price_of_anarchy)*.

    [Braess's paradox](https://en.wikipedia.org/wiki/Braess%27s_paradox) is not theoretical. Seoul, Stuttgart, and New York all observed traffic *improvements* after closing roads. Conversely, new roads in highly congested networks have sometimes worsened average travel times.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ## Implementation

    The simulation maintains a shared `LinkCounts` object tracking how many cars are currently on each link. Each `Car` process:

    1. Observes current link counts and computes expected travel time for each available route.
    2. Greedily picks the route with minimum expected time.
    3. Traverses each link in sequence, incrementing the count on entry and decrementing on exit; the delay is fixed at the count observed on entry.

    Two runs are compared: one with only the top and bottom routes, one with the $AB$ shortcut added. The simulation uses a probabilistic [logit](https://en.wikipedia.org/wiki/Logit) choice rule so that convergence to Nash equilibrium is smooth rather than instant.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    n_rounds_slider = mo.ui.slider(
        start=10,
        stop=200,
        step=10,
        value=80,
        label="Number of rounds",
    )

    beta_slider = mo.ui.slider(
        start=0.1,
        stop=2.0,
        step=0.1,
        value=0.5,
        label="Sensitivity (β)",
    )

    seed_input = mo.ui.number(
        value=192,
        step=1,
        label="Random seed",
    )

    run_button = mo.ui.run_button(label="Run simulation")

    mo.vstack([
        n_rounds_slider,
        beta_slider,
        seed_input,
        run_button,
    ])
    return beta_slider, n_rounds_slider, seed_input


@app.cell
def _(beta_slider, n_rounds_slider, seed_input):
    N_ROUNDS = int(n_rounds_slider.value)
    BETA = float(beta_slider.value)
    SEED = int(seed_input.value)
    N_DRIVERS = 4000
    CAPACITY = 100.0
    CONST_DELAY = 45.0
    return BETA, CAPACITY, CONST_DELAY, N_DRIVERS, N_ROUNDS, SEED


@app.cell
def _(CAPACITY, CONST_DELAY):
    def route_times(n_top, n_bot, n_short):
        n_sa = n_top + n_short
        n_bt = n_bot + n_short
        t_top = n_sa / CAPACITY + CONST_DELAY
        t_bot = CONST_DELAY + n_bt / CAPACITY
        t_short = n_sa / CAPACITY + n_bt / CAPACITY
        return t_top, t_bot, t_short

    return (route_times,)


@app.cell
def _(BETA, math):
    def logit_split(times):
        vals = [math.exp(-BETA * t) for t in times]
        total = sum(vals)
        return [v / total for v in vals]

    return (logit_split,)


@app.cell
def _(N_DRIVERS, N_ROUNDS, Process, logit_split, route_times):
    class RoutingGame(Process):
        def init(self, has_shortcut, history):
            self.has_shortcut = has_shortcut
            self.history = history
            self._n_top = N_DRIVERS // 2
            self._n_bot = N_DRIVERS - N_DRIVERS // 2
            self._n_short = 0

        async def run(self):
            for _ in range(N_ROUNDS):
                await self.timeout(1.0)
                t_top, t_bot, t_short = route_times(self._n_top, self._n_bot, self._n_short)
                if self.has_shortcut:
                    probs = logit_split([t_top, t_bot, t_short])
                    self._n_top = round(N_DRIVERS * probs[0])
                    self._n_bot = round(N_DRIVERS * probs[1])
                    self._n_short = N_DRIVERS - self._n_top - self._n_bot
                else:
                    probs = logit_split([t_top, t_bot])
                    self._n_top = round(N_DRIVERS * probs[0])
                    self._n_bot = N_DRIVERS - self._n_top
                    self._n_short = 0
                t_top2, t_bot2, t_short2 = route_times(self._n_top, self._n_bot, self._n_short)
                mean_t = (
                    self._n_top * t_top2 + self._n_bot * t_bot2 + self._n_short * t_short2
                ) / N_DRIVERS
                self.history.append({
                    "round": self.now,
                    "n_top": self._n_top,
                    "n_bot": self._n_bot,
                    "n_short": self._n_short,
                    "t_top": t_top2,
                    "t_bot": t_bot2,
                    "t_short": t_short2,
                    "mean": mean_t,
                })

    return (RoutingGame,)


@app.cell
def _(Environment, RoutingGame):
    def simulate(has_shortcut):
        history = []
        env = Environment()
        RoutingGame(env, has_shortcut, history)
        env.run()
        return history

    return (simulate,)


@app.cell
def _(CAPACITY, CONST_DELAY, N_DRIVERS, SEED, pl, random, simulate):
    random.seed(SEED)
    hist_no = simulate(has_shortcut=False)
    hist_yes = simulate(has_shortcut=True)
    df_no = pl.DataFrame(hist_no)
    df_yes = pl.DataFrame(hist_yes)
    eq_no = hist_no[-1]["mean"]
    eq_yes = hist_yes[-1]["mean"]
    n_half = N_DRIVERS / 2
    t_theory_no = n_half / CAPACITY + CONST_DELAY
    t_theory_yes = N_DRIVERS / CAPACITY + N_DRIVERS / CAPACITY
    return df_no, df_yes, eq_no, eq_yes, t_theory_no, t_theory_yes


@app.cell(hide_code=True)
def _(
    CAPACITY,
    CONST_DELAY,
    N_DRIVERS,
    eq_no,
    eq_yes,
    mo,
    t_theory_no,
    t_theory_yes,
):
    mo.md(f"""
    ## Results

    - Nash equilibrium **without** shortcut: **{eq_no:.2f}**
    - Nash equilibrium **with** shortcut: **{eq_yes:.2f}**
    - Adding the shortcut increased travel time by **{eq_yes - eq_no:.2f}** units
      ({100 * (eq_yes / eq_no - 1):.1f}% worse for every driver)

    Theory without shortcut (50/50 split): {t_theory_no:.2f}

    Theory with shortcut (all on SA→AB→BT): {t_theory_yes:.2f}

    Parameters: {N_DRIVERS} drivers, capacity={CAPACITY:.0f}, constant delay={CONST_DELAY}
    """)
    return


@app.cell
def _(alt, df_no, df_yes, pl):
    df_no_plot = df_no.select(["round", "mean"]).with_columns(
        pl.lit("without shortcut").alias("scenario")
    )
    df_yes_plot = df_yes.select(["round", "mean"]).with_columns(
        pl.lit("with shortcut").alias("scenario")
    )
    df_plot = pl.concat([df_no_plot, df_yes_plot])
    chart = (
        alt.Chart(df_plot)
        .mark_line()
        .encode(
            x=alt.X("round:Q", title="Round"),
            y=alt.Y("mean:Q", title="Mean travel time"),
            color=alt.Color("scenario:N", title="Network"),
            tooltip=["round:Q", "scenario:N", "mean:Q"],
        )
        .properties(title="Braess's Paradox: Convergence to Nash Equilibrium")
    )
    chart
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ## Understanding the Math

    ### Nash equilibrium

    A Nash equilibrium is a situation where every player has chosen a strategy and no single player can improve their own outcome by switching to a different strategy so long as everyone else stays put. Think of it as a stable fixed point: if you woke up one morning in a Nash equilibrium, you would have no reason to change what you are doing. Crucially, a Nash equilibrium need not be the best possible outcome for everyone collectively.

    ### The paradox, step by step

    Label the number of cars $N$ and suppose the congested links have delay $\alpha \cdot n$ where $n$ is the number of cars currently using that link. Without the shortcut, traffic splits evenly: $N/2$ cars use each route. Each driver's travel time is $(N/2)\alpha + c$, where $c$ is the fixed delay on the non-congested link. Neither route is faster than the other, so no driver wants to switch — that is Nash equilibrium.

    Now add the shortcut $A \to B$ with near-zero travel time $\varepsilon$. A single driver considering a switch reasons: "Link $AB$ is essentially free. If I take $SA$, cross to $B$, and take $BT$, I avoid the fixed cost $c$." If that driver is the only one to switch, it looks cheaper. But every driver makes the same calculation simultaneously. At the new equilibrium, all $N$ drivers pile onto $SA$ and $BT$:

    $$t_{\text{shortcut}} = N\alpha + \varepsilon + N\alpha = 2N\alpha + \varepsilon$$

    Since $2N\alpha > (N/2)\alpha + c$ for typical parameters, everyone is worse off than before the shortcut was built.

    ### The price of anarchy

    The social optimum would split traffic evenly at cost $(N/2)\alpha + c$, but selfish routing delivers $2N\alpha + \varepsilon$. The price of anarchy exceeds 1, meaning individual rationality destroys collective welfare.

    The [Prisoner's Dilemma](https://en.wikipedia.org/wiki/Prisoner's_dilemma) is the best-known example of this tension. Two suspects each choose independently to cooperate or defect. Defecting is a dominant strategy: it is better for you regardless of what the other person does. Yet if both defect, both get a worse outcome than if both had cooperated. Braess's paradox is the same logic scaled to $N$ drivers.

    ### The logit model

    The simulation uses a probabilistic choice rule: the probability a driver picks route $r$ is proportional to $\exp(-\beta \cdot t_r)$, where $t_r$ is the expected travel time on route $r$ and $\beta$ is a sensitivity parameter. When $\beta$ is large, drivers strongly prefer the fastest route and the outcome approaches the pure Nash equilibrium. When $\beta$ is small, drivers choose nearly randomly and the paradox weakens. The parameter $\beta$ captures how responsive real drivers are to time differences.
    """)
    return


if __name__ == "__main__":
    app.run()