Skip to content
Go back

Chaotic Bifurcations in the Logistic Map

from fastcore.utils import *
from IPython.display import HTML,Markdown,display
from dialoghelper.stdtools import *
from fastcore.tools import *
import numpy as np, matplotlib.pyplot as plt, types, dataclasses, collections, time
import numpy as np

I’d like to learn about chaos theory. In particular, I’ve heard that the logistic equation can be used to model population growth, and that it leads to “bifurcations”. I don’t understand. Can you walk me thru this gradually please

🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠

Great topic! The logistic equation is a wonderful gateway into chaos theory. Let’s build up to bifurcations step by step.

Starting point: Population growth

Imagine you’re modeling a population (say, rabbits). The simplest model says: next year’s population depends on this year’s population. If we call the population at time n as xnx_n, we might write:

xn+1=rxn(1xn)x_{n+1} = r \cdot x_n \cdot (1 - x_n)

This is the logistic map. Here:

  • xnx_n is the population as a fraction of the maximum possible (so it’s between 0 and 1)
  • rr is the “growth rate” parameter
  • The (1xn)(1 - x_n) term represents limited resources—as population grows, growth slows down

The key insight is: what happens over many generations depends dramatically on the value of r.

Before we go further, does this equation make intuitive sense to you? Can you see why:

  1. When xnx_n is small, the population grows?
  2. When xnx_n is close to 1, growth is suppressed?

Why is it called the “logistic map” please?

🧠🧠🧠🧠🧠🧠🧠🧠

Good question! The name comes from the French mathematician Pierre François Verhulst, who in the 1830s-40s developed the “logistic equation” to model population growth with limited resources.

The word “logistic” itself likely comes from the French logistique, related to logis (lodging/supplies). Verhulst may have chosen it to evoke the idea of “provisions” or “sustenance”—the limited resources that constrain population growth.

As for “map”—in mathematics, a map is just another word for a function that takes an input and produces an output. Since this equation maps one population value xnx_n to the next value xn+1x_{n+1}, we call it a map.

So “logistic map” = a function (map) based on Verhulst’s logistic growth model.

Ready to explore what happens when we iterate this map many times?

No, I’d like to build intuition first.

I get that rxnr x_n would be a way to express growth, so that r=1.10r=1.10 would represent 10% growth per unit time.

But What is the intuition behind the (1x)(1-x) ? Just that as xx gets bigger (approaching the maximum of 1), then there should should be a factor that goes closer and closer to 00 and therefore tends to limit growth?

🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠

Exactly right! You’ve got the core intuition.

The (1xn)(1 - x_n) term represents environmental resistance—things like limited food, space, disease, predation. When the population is small (say xn=0.1x_n = 0.1), this factor is large (0.9), so growth is mostly uninhibited. When population is near capacity (xn=0.9x_n = 0.9), the factor is small (0.1), severely limiting growth.

A nice way to think about it: the product xn(1xn)x_n(1 - x_n) is maximized when xn=0.5x_n = 0.5—medium-sized populations grow fastest. Too small and there aren’t enough individuals to reproduce; too large and resources run out.

One important thing to note: because we’re using the fraction of maximum capacity, the value of rr here isn’t quite like a simple percentage growth rate anymore. In this formulation, interesting dynamics happen when rr is between about 1 and 4. (If r>4r > 4, the population can overshoot and go negative, which breaks the model!)

Do you want to try iterating the equation by hand for a few steps with a specific rr value, or would you prefer to write some code to see what happens over many generations?

First I just want to see what growth looks like. I am told that there are also “bifurcations” to understand, which I understand to be a way of talking about how overall behavior is different for different values of rr. But let me start gradually.

Please can you generate code for a dynamic chart in Altair, where I a slider can control the value of rr, and then I can see the chart showing how x changes from a small initial value of 0.05.

🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠

Here’s an interactive chart with a slider for rr:

import altair as alt
import pandas as pd
import numpy as np

# Generate data for different r values
def logistic_series(r, x0=0.05, n=50):
    x = [x0]
    for _ in range(n-1):
        x.append(r * x[-1] * (1 - x[-1]))
    return x

# Create data for a range of r values
data = []
for r in np.linspace(0.5, 4.0, 71):  # r from 0.5 to 4.0 in steps of 0.05
    xs = logistic_series(r)
    for n, x in enumerate(xs):
        data.append({'r': round(r, 2), 'n': n, 'x': x})

df = pd.DataFrame(data)

# Create slider
slider = alt.binding_range(min=0.5, max=4.0, step=0.05, name='r = ')
selection = alt.param('r_val', value=2.0, bind=slider)

# Chart
alt.Chart(df).mark_line(point=True).encode(
    x=alt.X('n:Q', title='Generation'),
    y=alt.Y('x:Q', title='Population (x)', scale=alt.Scale(domain=[0, 1])),
).add_params(selection).transform_filter(
    alt.datum.r == selection
).properties(width=500, height=300, title='Logistic Map: Population over Time')

Try sliding rr slowly from low values (like 1.0) up to higher values (like 3.5 and beyond). What patterns do you notice at different ranges of rr?

def show_vega_chart_iframe(chart):
    import base64
    from IPython.display import HTML, IFrame
    import base64
    b64 = base64.b64encode(chart.to_html().encode()).decode()
    return IFrame(src=f'data:text/html;base64,{b64}', width=600,height=400)
import altair as alt
import pandas as pd
import numpy as np

# Generate data for different r values
def logistic_series(r, x0=0.05, n=50):
    x = [x0]
    for _ in range(n-1):
        x.append(r * x[-1] * (1 - x[-1]))
    return x

# Create data for a range of r values
data = []
for r in np.linspace(0.5, 4.0, 71):  # r from 0.5 to 4.0 in steps of 0.05
    xs = logistic_series(r)
    for n, x in enumerate(xs):
        data.append({'r': round(r, 2), 'n': n, 'x': x})

df = pd.DataFrame(data)

# Create slider
slider = alt.binding_range(min=0.5, max=4.0, step=0.05, name='r = ')
selection = alt.param('r_val', value=2.0, bind=slider)

# Chart
c = alt.Chart(df).mark_line(point=True).encode(
    x=alt.X('n:Q', title='Generation'),
    y=alt.Y('x:Q', title='Population (x)', scale=alt.Scale(domain=[0, 1])),
).add_params(selection).transform_filter(
    alt.datum.r == selection
).properties(width=500, height=300, title='Logistic Map: Population over Time')
show_vega_chart_iframe(c)

Okay. Let’s study the bifurcations. So I am going to want rr on the left-right axis I suppose, and then on the vertical axis I guess I want … what?

🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠

Great question! Think about it this way: at r = 3.5, your chart shows the population bouncing between 4 distinct values (roughly 0.38, 0.50, 0.83, 0.87).

So on the vertical axis, you want to plot the values that x settles into after the initial transient dies out.

For each r value, you’d:

  1. Run the iteration many times to let it “settle”
  2. Record the last several x values it visits
  3. Plot those as points at that r

For r = 2.5, you’d see just 1 point (stable fixed point). For r = 3.5, you’d see 4 points. For r = 3.9, you’d see a chaotic scatter of points.

Does that make sense? Want to try coding it?


Note: Some earlier messages were removed due to context limits—there were originally 11 messages, now 5. Let me know if you need me to recap anything.

import matplotlib.pyplot as plt
import numpy as np

r_values = np.linspace(2.5, 4.0, 1000)
iterations = 1000
last = 100  # keep last 100 values

plt.figure(figsize=(12, 8))

for r in r_values:
    x = 0.5
    for _ in range(iterations - last):
        x = r * x * (1 - x)  # discard transient
    xs = []
    for _ in range(last):
        x = r * x * (1 - x)
        xs.append(x)
    plt.plot([r] * last, xs, 'k,', markersize=0.5)

plt.xlabel('r')
plt.ylabel('x (stable values)')
plt.title('Bifurcation Diagram of the Logistic Map')
plt.show()

Does anyone know why there is that vertical empty region around r=3.83r=3.83 roughly, where the number of stable xx values suddenly goes down again to 3 after apparently being much higher?

🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠

That’s a wonderful observation! That’s called a periodic window (or “island of stability”).

Around r ≈ 3.83, something special happens: a stable period-3 cycle appears. The system, which was chaotic, suddenly settles into bouncing between just 3 values!

This is actually famous in mathematics. Li and Yorke wrote a paper called “Period Three Implies Chaos” (1975) showing that if a system has a period-3 cycle, it must also have cycles of every other period—a hallmark of chaos.

If you zoom in on that window, you’d see it has the same bifurcation structure as the whole diagram—it splits into 6, then 12, etc., before going chaotic again. This self-similarity repeats at every scale!

Would you like to zoom into that region to see the structure more clearly?


Context note: Some earlier messages were removed due to context limits—originally 14 messages, now 8.

Interactive Bifurcation Explorer

Is there a way to use Vega/Altair to make the above chart zoomable? it seems like a tricky problem, because there’s too much data to precompute all of it, so presumably it would be necessary to recompute every time the zoom region is finalized.

🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠

Unfortunately, pure Altair/Vega isn’t well-suited for this. It’s declarative—the data is baked into the spec sent to the browser, with no way to call Python when zoom changes.

Better options for dynamic recomputation:

  1. ipywidgets + matplotlib — simplest; use sliders to control the r range, recompute on change
  2. Panel + HoloViews — supports dynamic ranges with Python callbacks
  3. Bokeh — can do Python callbacks on zoom events

Want to try the ipywidgets approach? It would give you two sliders (r_min, r_max) that recompute and redraw the bifurcation diagram for that region.

I’d really prefer to do it all in JS so that I can host this on a static site.

🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠

That’s a great approach! Here’s a self-contained HTML file with a Canvas-based zoomable bifurcation diagram:

Logistic Map Bifurcation Diagram

r: [2.5, 4.0], x: [0, 1]

Click and drag to zoom into a region

You can save this as an HTML file and open it directly in a browser. Click and drag to zoom into any region—it recomputes the diagram in JS each time.

That looks promising but I’ll need it an iframe in order to render it in the notebook nondestructively, please.

🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠🧠

from IPython.display import IFrame
import base64

html = '''<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: sans-serif; margin: 10px; }
    canvas { border: 1px solid #ccc; cursor: crosshair; }
    .controls { margin: 10px 0; }
    button { margin-right: 10px; padding: 5px 15px; }
  </style>
</head>
<body>
  <h4>Logistic Map Bifurcation Diagram</h4>
  <div class="controls">
    <button onclick="resetZoom()">Reset Zoom</button>
    <span id="info">r: [2.5, 4.0], x: [0, 1]</span>
  </div>
  <canvas id="canvas" width="700" height="450"></canvas>
  <p>Click and drag to zoom into a region</p>
  <script>
const canvas = document.getElementById('canvas'), ctx = canvas.getContext('2d'), info = document.getElementById('info')
let rMin = 2.5, rMax = 4.0, xMin = 0, xMax = 1, selecting = false, selStart = null, selEnd = null

function draw() {
  ctx.fillStyle = 'white'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  ctx.fillStyle = 'rgba(0,0,0,0.3)'
  const rSteps = canvas.width * 2, iters = 1000, last = 100
  for (let i = 0; i < rSteps; i++) {
    const r = rMin + (rMax - rMin) * i / rSteps
    let x = 0.5
    for (let j = 0; j < iters - last; j++) x = r * x * (1 - x)
    for (let j = 0; j < last; j++) {
      x = r * x * (1 - x)
      if (x >= xMin && x <= xMax) {
        const px = (r - rMin) / (rMax - rMin) * canvas.width
        const py = canvas.height - (x - xMin) / (xMax - xMin) * canvas.height
        ctx.fillRect(px, py, 1, 1)
      }
    }
  }
  info.textContent = `r: [${rMin.toFixed(4)}, ${rMax.toFixed(4)}], x: [${xMin.toFixed(4)}, ${xMax.toFixed(4)}]`
}

function toCoords(e) {
  const rect = canvas.getBoundingClientRect()
  const px = e.clientX - rect.left, py = e.clientY - rect.top
  return { r: rMin + (rMax - rMin) * px / canvas.width, x: xMax - (xMax - xMin) * py / canvas.height }
}

canvas.onmousedown = e => { selecting = true; selStart = toCoords(e); selEnd = selStart }
canvas.onmousemove = e => {
  if (!selecting) return
  selEnd = toCoords(e)
  draw()
  const x1 = (selStart.r - rMin) / (rMax - rMin) * canvas.width
  const y1 = canvas.height - (selStart.x - xMin) / (xMax - xMin) * canvas.height
  const x2 = (selEnd.r - rMin) / (rMax - rMin) * canvas.width
  const y2 = canvas.height - (selEnd.x - xMin) / (xMax - xMin) * canvas.height
  ctx.strokeStyle = 'blue'
  ctx.strokeRect(Math.min(x1,x2), Math.min(y1,y2), Math.abs(x2-x1), Math.abs(y2-y1))
}
canvas.onmouseup = e => {
  if (!selecting) return
  selecting = false
  selEnd = toCoords(e)
  const newRMin = Math.min(selStart.r, selEnd.r), newRMax = Math.max(selStart.r, selEnd.r)
  const newXMin = Math.min(selStart.x, selEnd.x), newXMax = Math.max(selStart.x, selEnd.x)
  if (newRMax - newRMin > 0.01 && newXMax - newXMin > 0.01) { rMin = newRMin; rMax = newRMax; xMin = newXMin; xMax = newXMax; draw() }
}

function resetZoom() { rMin = 2.5; rMax = 4.0; xMin = 0; xMax = 1; draw() }

draw()
  </script>
</body>
</html>'''

IFrame(src=f'data:text/html;base64,{base64.b64encode(html.encode()).decode()}', width=750, height=580)
from IPython.display import IFrame
import base64

html = '''<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: sans-serif; margin: 10px; }
    canvas { border: 1px solid #ccc; cursor: crosshair; }
    .controls { margin: 10px 0; }
    button { margin-right: 10px; padding: 5px 15px; }
  </style>
</head>
<body>
  <h4>Logistic Map Bifurcation Diagram</h4>
  <div class="controls">
    <button onclick="resetZoom()">Reset Zoom</button>
    <span id="info">r: [2.5, 4.0], x: [0, 1]</span>
  </div>
  <canvas id="canvas" width="700" height="450"></canvas>
  <p>Click and drag to zoom into a region</p>
  <script>
const canvas = document.getElementById('canvas'), ctx = canvas.getContext('2d'), info = document.getElementById('info')
let rMin = 2.5, rMax = 4.0, xMin = 0, xMax = 1, selecting = false, selStart = null, selEnd = null

function draw() {
  ctx.fillStyle = 'white'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  ctx.fillStyle = 'rgba(0,0,0,0.3)'
  const rSteps = canvas.width * 2, iters = 1000, last = 100
  for (let i = 0; i < rSteps; i++) {
    const r = rMin + (rMax - rMin) * i / rSteps
    let x = 0.5
    for (let j = 0; j < iters - last; j++) x = r * x * (1 - x)
    for (let j = 0; j < last; j++) {
      x = r * x * (1 - x)
      if (x >= xMin && x <= xMax) {
        const px = (r - rMin) / (rMax - rMin) * canvas.width
        const py = canvas.height - (x - xMin) / (xMax - xMin) * canvas.height
        ctx.fillRect(px, py, 1, 1)
      }
    }
  }
  info.textContent = `r: [${rMin.toFixed(4)}, ${rMax.toFixed(4)}], x: [${xMin.toFixed(4)}, ${xMax.toFixed(4)}]`
}

function toCoords(e) {
  const rect = canvas.getBoundingClientRect()
  const px = e.clientX - rect.left, py = e.clientY - rect.top
  return { r: rMin + (rMax - rMin) * px / canvas.width, x: xMax - (xMax - xMin) * py / canvas.height }
}

canvas.onmousedown = e => { selecting = true; selStart = toCoords(e); selEnd = selStart }
canvas.onmousemove = e => {
  if (!selecting) return
  selEnd = toCoords(e)
  draw()
  const x1 = (selStart.r - rMin) / (rMax - rMin) * canvas.width
  const y1 = canvas.height - (selStart.x - xMin) / (xMax - xMin) * canvas.height
  const x2 = (selEnd.r - rMin) / (rMax - rMin) * canvas.width
  const y2 = canvas.height - (selEnd.x - xMin) / (xMax - xMin) * canvas.height
  ctx.strokeStyle = 'blue'
  ctx.strokeRect(Math.min(x1,x2), Math.min(y1,y2), Math.abs(x2-x1), Math.abs(y2-y1))
}
canvas.onmouseup = e => {
  if (!selecting) return
  selecting = false
  selEnd = toCoords(e)
  const newRMin = Math.min(selStart.r, selEnd.r), newRMax = Math.max(selStart.r, selEnd.r)
  const newXMin = Math.min(selStart.x, selEnd.x), newXMax = Math.max(selStart.x, selEnd.x)
  if (newRMax - newRMin > 0.01 && newXMax - newXMin > 0.01) { rMin = newRMin; rMax = newRMax; xMin = newXMin; xMax = newXMax; draw() }
}

function resetZoom() { rMin = 2.5; rMax = 4.0; xMin = 0; xMax = 1; draw() }

draw()
  </script>
</body>
</html>'''

IFrame(src=f'data:text/html;base64,{base64.b64encode(html.encode()).decode()}', width=750, height=580)

Share this post on:

Next Post
AI Chat: The Last Messiah and Herzog's penguin