Haleshot commited on
Commit
d23b988
·
unverified ·
1 Parent(s): 3cd7fd0

Add `Central Limit Theorem`

Browse files
probability/18_central_limit_theorem.py ADDED
@@ -0,0 +1,944 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = [
4
+ # "marimo",
5
+ # "matplotlib==3.10.1",
6
+ # "scipy==1.15.2",
7
+ # "numpy==2.2.4",
8
+ # "tqdm==4.66.2",
9
+ # "plotly==5.18.0",
10
+ # ]
11
+ # ///
12
+
13
+ import marimo
14
+
15
+ __generated_with = "0.11.30"
16
+ app = marimo.App(width="medium", app_title="Central Limit Theorem")
17
+
18
+
19
+ @app.cell(hide_code=True)
20
+ def _(mo):
21
+ mo.md(
22
+ r"""
23
+ # Central Limit Theorem
24
+
25
+ _This notebook is a computational companion to ["Probability for Computer Scientists"](https://chrispiech.github.io/probabilityForComputerScientists/en/part4/clt/), by Stanford professor Chris Piech._
26
+
27
+ The Central Limit Theorem (CLT) is one of the most important concepts in probability theory and statistics. It explains why many real-world distributions tend to be normal, even when the underlying processes are not.
28
+ """
29
+ )
30
+ return
31
+
32
+
33
+ @app.cell(hide_code=True)
34
+ def _(mo):
35
+ mo.md(
36
+ r"""
37
+ ## Central Limit Theorem Statement
38
+
39
+ There are two ways to state the central limit theorem:
40
+
41
+ ### Sum Version
42
+
43
+ Let $X_1, X_2, \dots, X_n$ be independent and identically distributed random variables. The sum of these random variables approaches a normal distribution as $n \rightarrow \infty$:
44
+
45
+ $n∑i=1Xi∼N(n⋅μ,n⋅σ2)\sum_{i=1}^{n}X_i \sim \mathcal{N}(n \cdot \mu, n \cdot \sigma^2)$
46
+
47
+ Where $\mu = E[X_i]$ and $\sigma^2 = \text{Var}(X_i)$. Since each $X_i$ is identically distributed, they share the same expectation and variance.
48
+
49
+ ### Average Version
50
+
51
+ Let $X_1, X_2, \dots, X_n$ be independent and identically distributed random variables. The average of these random variables approaches a normal distribution as $n \rightarrow \infty$:
52
+
53
+ $\frac{1}{n} ∑i=1Xi∼N(μ,σ2n)\frac{1}{n}\sum_{i=1}^{n}X_i \sim \mathcal{N}(\mu, \frac{\sigma^2}{n})$
54
+
55
+ Where $\mu = E[X_i]$ and $\sigma^2 = \text{Var}(X_i)$.
56
+
57
+ The CLT is incredible because it applies to almost any distribution (as long as it has a finite mean and variance), regardless of its shape.
58
+ """
59
+ )
60
+ return
61
+
62
+
63
+ @app.cell(hide_code=True)
64
+ def _(mo):
65
+ mo.md(
66
+ r"""
67
+ ## Central Limit Theorem Intuition
68
+
69
+ Let's explore what happens when you add random variables together. For example, what if we add 100 different uniform random variables?
70
+
71
+ ```python
72
+ from random import random
73
+
74
+ def add_100_uniforms():
75
+ total = 0
76
+ for i in range(100):
77
+ # returns a sample from uniform(0, 1)
78
+ x_i = random()
79
+ total += x_i
80
+ return total
81
+ ```
82
+
83
+ The value returned by this function will be a random variable. Click the button below to run the function and observe the resulting value of →taltotal:
84
+ """
85
+ )
86
+ return
87
+
88
+
89
+ @app.cell(hide_code=True)
90
+ def _(mo):
91
+ run_button = mo.ui.run_button(label="Run add_100_uniforms()")
92
+
93
+ run_button.center()
94
+ return (run_button,)
95
+
96
+
97
+ @app.cell(hide_code=True)
98
+ def _(mo, random, run_button):
99
+ def add_100_uniforms():
100
+ total = 0
101
+ for i in range(100):
102
+ # returns a sample from uniform(0, 1)
103
+ x_i = random.random()
104
+ total += x_i
105
+ return total
106
+
107
+ # Display the result when the button is clicked
108
+ if run_button.value:
109
+ uniform_result = add_100_uniforms()
110
+ display = mo.md(f"**total**: {uniform_result:.5f}")
111
+ else:
112
+ display = mo.md("")
113
+
114
+ display
115
+ return add_100_uniforms, display, uniform_result
116
+
117
+
118
+ @app.cell(hide_code=True)
119
+ def _(mo):
120
+ mo.md(r"""What does →taltotal look like as a distribution? Let's calculate →taltotal many times and visualize the histogram of values it produces.""")
121
+ return
122
+
123
+
124
+ @app.cell(hide_code=True)
125
+ def _(mo):
126
+ # Simulation control
127
+ run_simulation_button = mo.ui.button(
128
+ value=0,
129
+ on_click=lambda value: value + 1,
130
+ label="Run 10,000 more samples",
131
+ kind="warn"
132
+ )
133
+
134
+ run_simulation_button.center()
135
+ return (run_simulation_button,)
136
+
137
+
138
+ @app.cell(hide_code=True)
139
+ def _(add_100_uniforms, go, mo, np, run_simulation_button, stats, time):
140
+ # store the results
141
+ def get_simulation_results():
142
+ if not hasattr(get_simulation_results, "results"):
143
+ get_simulation_results.results = []
144
+ get_simulation_results.last_button_value = -1 # track button clicks
145
+ return get_simulation_results
146
+
147
+ # grab the results
148
+ sim_storage = get_simulation_results()
149
+ simulation_results = sim_storage.results
150
+
151
+ # Check if button was clicked (value changed)
152
+ if run_simulation_button.value != sim_storage.last_button_value:
153
+ # Update the last seen button value
154
+ sim_storage.last_button_value = run_simulation_button.value
155
+
156
+ with mo.status.spinner(title="Running simulation...") as progress_status:
157
+ sim_count = 10000
158
+ new_results = []
159
+ for _ in mo.status.progress_bar(range(sim_count)):
160
+ sim_result = add_100_uniforms()
161
+ new_results.append(sim_result)
162
+ time.sleep(0.0001) # tiny pause
163
+
164
+ simulation_results.extend(new_results)
165
+
166
+ progress_status.update(f"✅ Added {sim_count:,} samples (total: {len(simulation_results):,})")
167
+
168
+ if simulation_results:
169
+ # Numbers
170
+ mean = np.mean(simulation_results)
171
+ std_dev = np.std(simulation_results)
172
+
173
+ theoretical_mean = 100 * 0.5 # = 50
174
+ theoretical_variance = 100 * (1/12) # = 8.33...
175
+ theoretical_std = np.sqrt(theoretical_variance) # ≈ 2.89
176
+
177
+ # should be 10k times the click number (mainly for the y-axis label)
178
+ total_samples = run_simulation_button.value * 10000
179
+
180
+ fig = go.Figure()
181
+
182
+ # histogram of samples
183
+ fig.add_trace(go.Histogram(
184
+ x=simulation_results,
185
+ histnorm='probability density',
186
+ name='Sum Distribution',
187
+ marker_color='royalblue',
188
+ opacity=0.7
189
+ ))
190
+
191
+ x_vals = np.linspace(min(simulation_results), max(simulation_results), 1000)
192
+ y_vals = stats.norm.pdf(x_vals, theoretical_mean, theoretical_std)
193
+
194
+ fig.add_trace(go.Scatter(
195
+ x=x_vals,
196
+ y=y_vals,
197
+ mode='lines',
198
+ name='Normal approximation',
199
+ line=dict(color='red', width=2)
200
+ ))
201
+
202
+ fig.add_vline(
203
+ x=mean,
204
+ line_dash="dash",
205
+ line_width=1.5,
206
+ line_color="green",
207
+ annotation_text=f"Sample Mean: {mean:.2f}",
208
+ annotation_position="top right"
209
+ )
210
+
211
+ # some notes
212
+ fig.add_annotation(
213
+ x=0.02, y=0.95,
214
+ xref="paper", yref="paper",
215
+ text=f"Sum of 100 Uniform(0,1) variables<br>" +
216
+ f"Sample size: {total_samples:,}<br>" +
217
+ f"Sample mean: {mean:.2f} (expected: {theoretical_mean})<br>" +
218
+ f"Sample std: {std_dev:.2f} (expected: {theoretical_std:.2f})<br>" +
219
+ f"According to CLT: Normal({theoretical_mean}, {theoretical_variance:.2f})",
220
+ showarrow=False,
221
+ align="left",
222
+ bgcolor="white",
223
+ opacity=0.8
224
+ )
225
+
226
+ fig.update_layout(
227
+ title=f'Distribution of Sum of 100 Uniforms (Click #{run_simulation_button.value})',
228
+ xaxis_title='Values',
229
+ yaxis_title=f'Probability Density ({total_samples:,} runs)',
230
+ template='plotly_white',
231
+ height=500
232
+ )
233
+
234
+ # show
235
+ histogram = mo.ui.plotly(fig)
236
+ else:
237
+ histogram = mo.md("Click the button to run the simulation!")
238
+
239
+ # display
240
+ histogram
241
+ return (
242
+ fig,
243
+ get_simulation_results,
244
+ histogram,
245
+ mean,
246
+ new_results,
247
+ progress_status,
248
+ sim_count,
249
+ sim_result,
250
+ sim_storage,
251
+ simulation_results,
252
+ std_dev,
253
+ theoretical_mean,
254
+ theoretical_std,
255
+ theoretical_variance,
256
+ total_samples,
257
+ x_vals,
258
+ y_vals,
259
+ )
260
+
261
+
262
+ @app.cell(hide_code=True)
263
+ def _(mo):
264
+ mo.md(
265
+ r"""
266
+ That is interesting! The sum of 100 independent uniforms looks normal. Is that a special property of uniforms? No! It turns out to work for almost any type of distribution (as long as the distribution has finite mean and variance).
267
+
268
+ - Sum of 40 $X_i$ where $X_i \sim \text{Beta}(a = 5, b = 4)$? Normal.
269
+ - Sum of 90 $X_i$ where $X_i \sim \text{Poisson}(\lambda = 4)$? Normal.
270
+ - Sum of 50 dice-rolls? Normal.
271
+ - Average of 10000 $X_i$ where $X_i \sim \text{Exp}(\lambda = 8)$? Normal.
272
+
273
+ For any distribution, the sum or average of a sufficiently large number of independent, identically distributed random variables will be approximately normally distributed.
274
+ """
275
+ )
276
+ return
277
+
278
+
279
+ @app.cell(hide_code=True)
280
+ def _(mo):
281
+ mo.md(
282
+ r"""
283
+ ## Continuity Correction
284
+
285
+ When using the Central Limit Theorem with discrete random variables (like a Binomial or Poisson), we need to apply a continuity correction. This is because we're approximating a discrete distribution with a continuous one (normal).
286
+
287
+ The continuity correction involves adjusting the boundaries in probability calculations by ±0.5 to account for the discrete nature of the original variable.
288
+
289
+ You should use a continuity correction any time your normal is approximating a discrete random variable. The rules for a general continuity correction are the same as the rules for the [binomial-approximation continuity correction](http://marimo.app/https://github.com/marimo-team/learn/blob/main/probability/14_binomial_distribution.py).
290
+
291
+ In our example above, where we added 100 uniforms, a continuity correction isn't needed because the sum of uniforms is continuous. However, in examples with dice or other discrete distributions, a continuity correction would be necessary.
292
+ """
293
+ )
294
+ return
295
+
296
+
297
+ @app.cell(hide_code=True)
298
+ def _(mo):
299
+ mo.md(
300
+ r"""
301
+ ## Examples
302
+
303
+ Let's work through some practical examples to see how the Central Limit Theorem is applied.
304
+ """
305
+ )
306
+ return
307
+
308
+
309
+ @app.cell(hide_code=True)
310
+ def _(mo):
311
+ mo.md(
312
+ r"""
313
+ ### Example 1: Dice Game
314
+
315
+ You will roll a 6-sided dice 10 times. Let $X$ be the total value of all 10 dice: $X = X_1 + X_2 + \dots + X_{10}$. You win the game if $X \leq 25$ or $X \geq 45$. Use the central limit theorem to calculate the probability that you win.
316
+
317
+ Recall that for a single die roll $X_i$:
318
+
319
+ - $E[X_i] = 3.5$
320
+ - $\text{Var}(X_i) = \frac{35}{12}$
321
+
322
+ **Solution:**
323
+
324
+ Let $Y$ be the approximating normal distribution. By the Central Limit Theorem:
325
+
326
+ $Y∼N(10⋅E[Xi],10⋅Var(Xi))Y \sim \mathcal{N}(10 \cdot E[X_i], 10 \cdot \text{Var}(X_i))$
327
+
328
+ Substituting in the known values:
329
+
330
+ $Y∼N(10⋅3.5,10⋅3512)=N(35,29.2)Y \sim \mathcal{N}(10 \cdot 3.5, 10 \cdot \frac{35}{12}) = \mathcal{N}(35, 29.2)$
331
+
332
+ Now we calculate the probability:
333
+
334
+ $P(X≤25 or X≥45)P(X \leq 25 \text{ or } X \geq 45)$
335
+
336
+ $=P(X≤25)+P(X≥45)= P(X \leq 25) + P(X \geq 45)$
337
+
338
+ $≈P(Y<25.5)+P(Y>44.5) (Continuity Correction)\approx P(Y < 25.5) + P(Y > 44.5) \text{ (Continuity Correction)}$
339
+
340
+ $≈P(Y<25.5)+[1−P(Y<44.5)]\approx P(Y < 25.5) + [1 - P(Y < 44.5)]$
341
+
342
+ $≈Φ(25.5−35√29.2)+[1−Φ(44.5−35√29.2)]\approx \Phi\left(\frac{25.5 - 35}{\sqrt{29.2}}\right) + \left[1 - \Phi\left(\frac{44.5 - 35}{\sqrt{29.2}}\right)\right]$
343
+
344
+ $≈Φ(−1.76)+[1−Φ(1.76)]\approx \Phi(-1.76) + [1 - \Phi(1.76)]$
345
+
346
+ $≈0.039+(1−0.961)\approx 0.039 + (1 - 0.961)$
347
+
348
+ $≈0.078\approx 0.078$
349
+ So, the probability of winning the game is approximately 7.8%.
350
+ """
351
+ )
352
+ return
353
+
354
+
355
+ @app.cell(hide_code=True)
356
+ def _(create_dice_game_visualization, fig_to_image, mo):
357
+ # Display visualization
358
+ dice_game_fig = create_dice_game_visualization()
359
+ dice_game_image = mo.image(fig_to_image(dice_game_fig), width="100%")
360
+
361
+ dice_explanation = mo.md(
362
+ r"""
363
+ **Visualization Explanation:**
364
+
365
+ The graph shows the distribution of the sum of 10 dice rolls. The blue bars represent the actual probability mass function (PMF), while the red curve shows the normal approximation using the Central Limit Theorem.
366
+
367
+ The winning regions are shaded in orange:
368
+ - The left region where $X \leq 25$
369
+ - The right region where $X \geq 45$
370
+
371
+ The total probability of these regions is approximately 0.078 or 7.8%.
372
+
373
+ Notice how the normal approximation provides a good fit to the discrete distribution, demonstrating the power of the Central Limit Theorem.
374
+ """
375
+ )
376
+
377
+ mo.vstack([dice_game_image, dice_explanation])
378
+ return dice_explanation, dice_game_fig, dice_game_image
379
+
380
+
381
+ @app.cell(hide_code=True)
382
+ def _(mo):
383
+ mo.md(
384
+ r"""
385
+ ### Example 2: Algorithm Runtime Estimation
386
+
387
+ Say you have a new algorithm and you want to test its running time. You know the variance of the algorithm's run time is $\sigma^2 = 4 \text{ sec}^2$, but you want to estimate the mean run time $t$ in seconds.
388
+
389
+ You can run the algorithm repeatedly (IID trials). How many trials do you have to run so that your estimated runtime is within ±0.5 seconds of $t$ with 95% certainty?
390
+
391
+ Let $X_i$ be the run time of the $i$-th run (for $1 \leq i \leq n$).
392
+
393
+ **Solution:**
394
+
395
+ We need to find $n$ such that:
396
+
397
+ $0.95=P(−0.5≤∑ni=1Xin−t≤0.5)0.95 = P\left(-0.5 \leq \frac{\sum_{i=1}^n X_i}{n} - t \leq 0.5\right)$
398
+
399
+ By the central limit theorem, the sample mean follows a normal distribution.
400
+ We can standardize this to work with the standard normal:
401
+
402
+ $Z=(∑ni=1Xi)−nμσ√nZ = \frac{\left(\sum_{i=1}^n X_i\right) - n\mu}{\sigma \sqrt{n}}$
403
+
404
+ $=(∑ni=1Xi)−nt2√n= \frac{\left(\sum_{i=1}^n X_i\right) - nt}{2 \sqrt{n}}$
405
+
406
+ Rewriting our probability inequality so that the central term is $Z$:
407
+
408
+ $0.95=P(−0.5≤∑ni=1Xin−t≤0.5)0.95 = P\left(-0.5 \leq \frac{\sum_{i=1}^n X_i}{n} - t \leq 0.5\right)$
409
+
410
+ $=P(−0.5√n2≤Z≤0.5√n2)= P\left(\frac{-0.5 \sqrt{n}}{2} \leq Z \leq \frac{0.5 \sqrt{n}}{2}\right)$
411
+
412
+ And now we find the value of $n$ that makes this equation hold:
413
+
414
+ $0.95=Φ(√n4)−Φ(−√n4)0.95 = \Phi\left(\frac{\sqrt{n}}{4}\right) - \Phi\left(-\frac{\sqrt{n}}{4}\right)$
415
+
416
+ $4=Φ(√n4)−(1−Φ(√n4))= \Phi\left(\frac{\sqrt{n}}{4}\right) - \left(1 - \Phi\left(\frac{\sqrt{n}}{4}\right)\right)$
417
+
418
+ $=2Φ(√n4)−1= 2\Phi\left(\frac{\sqrt{n}}{4}\right) - 1$
419
+
420
+ Solving for $\Phi\left(\frac{\sqrt{n}}{4}\right)$:
421
+
422
+ $0.975=Φ(√n4)0.975 = \Phi\left(\frac{\sqrt{n}}{4}\right)$
423
+
424
+ $Φ−1(0.975)=√n4\Phi^{-1}(0.975) = \frac{\sqrt{n}}{4}$
425
+
426
+ $1.96=√n41.96 = \frac{\sqrt{n}}{4}$
427
+
428
+ $n=61.4n = 61.4$
429
+
430
+ Therefore, we need to run the algorithm 62 times to estimate the mean runtime within ±0.5 seconds with 95% confidence.
431
+ """
432
+ )
433
+ return
434
+
435
+
436
+ @app.cell(hide_code=True)
437
+ def _(create_algorithm_runtime_visualization, fig_to_image, mo):
438
+ # Display visualization
439
+ runtime_fig = create_algorithm_runtime_visualization()
440
+ runtime_image = mo.image(fig_to_image(runtime_fig), width="100%")
441
+
442
+ runtime_explanation = mo.md(
443
+ r"""
444
+ **Visualization Explanation:**
445
+
446
+ The graph illustrates how the standard error of the mean (SEM) decreases as the number of trials increases. The standard error is calculated as $\frac{\sigma}{\sqrt{n}}$.
447
+
448
+ - When we conduct 62 trials, the standard error is approximately 0.254 seconds.
449
+ - With a 95% confidence level, this gives us a margin of error of about ±0.5 seconds (1.96 × 0.254 ≈ 0.5).
450
+ - The shaded region shows how the confidence interval narrows as the number of trials increases.
451
+
452
+ This demonstrates why 62 trials are sufficient to meet our requirements of estimating the mean runtime within ±0.5 seconds with 95% confidence.
453
+ """
454
+ )
455
+
456
+ mo.vstack([runtime_image, runtime_explanation])
457
+ return runtime_explanation, runtime_fig, runtime_image
458
+
459
+
460
+ @app.cell(hide_code=True)
461
+ def _(mo):
462
+ mo.md(
463
+ r"""
464
+ ## Interactive CLT Explorer
465
+
466
+ Let's explore how the Central Limit Theorem works with different underlying distributions. You can select a distribution type and see how the distribution of the sample mean changes as the sample size increases.
467
+ """
468
+ )
469
+ return
470
+
471
+
472
+ @app.cell(hide_code=True)
473
+ def _(controls):
474
+ controls
475
+ return
476
+
477
+
478
+ @app.cell(hide_code=True)
479
+ def _(
480
+ distribution_type,
481
+ fig_to_image,
482
+ mo,
483
+ np,
484
+ plt,
485
+ run_explorer_button,
486
+ sample_size,
487
+ sim_count_slider,
488
+ stats,
489
+ ):
490
+ # Run simulation when button is clicked
491
+ if run_explorer_button.value:
492
+ # Set distribution parameters based on selection
493
+ if distribution_type.value == "uniform":
494
+ dist_name = "Uniform(0, 1)"
495
+ # For uniform(0,1): mean = 0.5, variance = 1/12
496
+ true_mean = 0.5
497
+ true_var = 1/12
498
+
499
+ # generate samples
500
+ def generate_sample():
501
+ return np.random.uniform(0, 1, sample_size.value)
502
+
503
+ elif distribution_type.value == "exponential":
504
+ rate = 1.0
505
+ dist_name = f"Exponential(λ={rate})"
506
+ # For exponential(λ): mean = 1/λ, variance = 1/λ²
507
+ true_mean = 1/rate
508
+ true_var = 1/(rate**2)
509
+
510
+ def generate_sample():
511
+ return np.random.exponential(1/rate, sample_size.value)
512
+
513
+ elif distribution_type.value == "binomial":
514
+ n_param, p = 10, 0.3
515
+ dist_name = f"Binomial(n={n_param}, p={p})"
516
+ # For binomial(n,p): mean = np, variance = np(1-p)
517
+ true_mean = n_param * p
518
+ true_var = n_param * p * (1-p)
519
+
520
+ def generate_sample():
521
+ return np.random.binomial(n_param, p, sample_size.value)
522
+
523
+ elif distribution_type.value == "poisson":
524
+ rate = 3.0
525
+ dist_name = f"Poisson(λ={rate})"
526
+ # For poisson(λ): mean = λ, variance = λ
527
+ true_mean = rate
528
+ true_var = rate
529
+
530
+ def generate_sample():
531
+ return np.random.poisson(rate, sample_size.value)
532
+
533
+ # Generate the simulation data using a spinner for progress
534
+ with mo.status.spinner(title="Running simulation...") as explorer_progress:
535
+ sample_means = []
536
+ original_samples = []
537
+
538
+ # Run simulations
539
+ for _ in mo.status.progress_bar(range(sim_count_slider.value)):
540
+ sample = generate_sample()
541
+
542
+ # Store the first simulation's individual values for visualizing original distribution
543
+ if len(original_samples) < 1000: # limit to prevent memory issues
544
+ original_samples.extend(sample)
545
+
546
+ # sample mean
547
+ sample_means.append(np.mean(sample))
548
+
549
+ # progress
550
+ explorer_progress.update(f"✅ Completed {sim_count_slider.value:,} simulations")
551
+
552
+ # Create visualization
553
+ explorer_fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
554
+
555
+ # Original distribution histogram
556
+ ax1.hist(original_samples, bins=30, density=True, alpha=0.7, color='royalblue')
557
+ ax1.set_title(f"Original Distribution: {dist_name}")
558
+
559
+ # Theoretical mean line
560
+ ax1.axvline(x=true_mean, color='red', linestyle='--',
561
+ label=f'True Mean = {true_mean:.3f}')
562
+
563
+ ax1.set_xlabel("Value")
564
+ ax1.set_ylabel("Density")
565
+ ax1.legend()
566
+
567
+ # Sample means histogram and normal approximation
568
+ sample_mean_mean = np.mean(sample_means)
569
+ sample_mean_std = np.std(sample_means)
570
+ expected_std = np.sqrt(true_var / sample_size.value) # CLT prediction
571
+
572
+ ax2.hist(sample_means, bins=30, density=True, alpha=0.7, color='forestgreen',
573
+ label=f'Sample Size = {sample_size.value}')
574
+
575
+ # Normal approximation from CLT
576
+ explorer_x = np.linspace(min(sample_means), max(sample_means), 1000)
577
+ explorer_y = stats.norm.pdf(explorer_x, true_mean, expected_std)
578
+ ax2.plot(explorer_x, explorer_y, 'r-', linewidth=2, label='CLT Normal Approximation')
579
+
580
+ # Add mean line
581
+ ax2.axvline(x=true_mean, color='purple', linestyle='--',
582
+ label=f'True Mean = {true_mean:.3f}')
583
+
584
+ ax2.set_title(f"Distribution of Sample Means\n(CLT Prediction: N({true_mean:.3f}, {true_var/sample_size.value:.5f}))")
585
+ ax2.set_xlabel("Sample Mean")
586
+ ax2.set_ylabel("Density")
587
+ ax2.legend()
588
+
589
+ # Add CLT description
590
+ explorer_fig.text(0.5, 0.01,
591
+ f"Central Limit Theorem: As sample size increases, the distribution of sample means approaches\n" +
592
+ f"a normal distribution with mean = {true_mean:.3f} and variance = {true_var:.3f}/{sample_size.value} = {true_var/sample_size.value:.5f}",
593
+ ha='center', fontsize=10, bbox=dict(facecolor='white', alpha=0.8))
594
+
595
+ plt.tight_layout(rect=[0, 0.05, 1, 1])
596
+
597
+ # Display plot
598
+ explorer_image = mo.image(fig_to_image(explorer_fig), width="100%")
599
+ else:
600
+ explorer_image = mo.md("Click the 'Run Simulation' button to see how the Central Limit Theorem works.")
601
+
602
+ explorer_image
603
+ return (
604
+ ax1,
605
+ ax2,
606
+ dist_name,
607
+ expected_std,
608
+ explorer_fig,
609
+ explorer_image,
610
+ explorer_progress,
611
+ explorer_x,
612
+ explorer_y,
613
+ generate_sample,
614
+ n_param,
615
+ original_samples,
616
+ p,
617
+ rate,
618
+ sample,
619
+ sample_mean_mean,
620
+ sample_mean_std,
621
+ sample_means,
622
+ true_mean,
623
+ true_var,
624
+ )
625
+
626
+
627
+ @app.cell(hide_code=True)
628
+ def _(mo):
629
+ mo.md(
630
+ r"""
631
+ ## 🤔 Test Your Understanding
632
+
633
+ /// details | What is the shape of the distribution of the sum of many independent random variables?
634
+ The sum of many independent random variables approaches a normal distribution, regardless of the shape of the original distributions (as long as they have finite mean and variance). This is the essence of the Central Limit Theorem.
635
+ ///
636
+
637
+ /// details | If $X_1, X_2, \dots, X_{100}$ are IID random variables with $E[X_i] = 5$ and $Var(X_i) = 9$, what is the distribution of their sum?
638
+ By the Central Limit Theorem, the sum $S = X_1 + X_2 + \dots + X_{100}$ follows a normal distribution with:
639
+
640
+ - Mean: $E[S] = 100 \cdot E[X_i] = 100 \cdot 5 = 500$
641
+ - Variance: $Var(S) = 100 \cdot Var(X_i) = 100 \cdot 9 = 900$
642
+
643
+ Therefore, $S \sim \mathcal{N}(500, 900)$, or equivalently $S \sim \mathcal{N}(500, 30^2)$.
644
+ ///
645
+
646
+ /// details | When do you need to apply a continuity correction when using the Central Limit Theorem?
647
+ You need to apply a continuity correction when you're using the normal approximation (through CLT) for a discrete random variable.
648
+
649
+ For example, when approximating a binomial or Poisson distribution with a normal distribution, you should adjust boundaries by ±0.5 to account for the discrete nature of the original variable. This makes the approximation more accurate.
650
+ ///
651
+
652
+ /// details | If $X_1, X_2, \dots, X_{n}$ are IID random variables, how does the variance of their sample mean $\bar{X} = \frac{1}{n}\sum_{i=1}^{n}X_i$ change as $n$ increases?
653
+ The variance of the sample mean decreases as the sample size $n$ increases. Specifically:
654
+
655
+ $Var(\bar{X}) = \frac{Var(X_i)}{n}$
656
+
657
+ This means that as we take more samples, the sample mean becomes more concentrated around the true mean of the distribution. This is why larger samples give more precise estimates.
658
+ ///
659
+
660
+ /// details | Why is the Central Limit Theorem so important in statistics?
661
+ The Central Limit Theorem is foundational in statistics because:
662
+
663
+ 1. It allows us to make inferences about population parameters using sample statistics, regardless of the population's distribution.
664
+ 2. It explains why the normal distribution appears so frequently in natural phenomena.
665
+ 3. It enables the construction of confidence intervals and hypothesis tests for means, even when the underlying population distribution is unknown.
666
+ 4. It justifies many statistical methods that assume normality, even when working with non-normal data, provided the sample size is large enough.
667
+
668
+ In essence, the CLT provides the theoretical justification for much of statistical inference.
669
+ ///
670
+ """
671
+ )
672
+ return
673
+
674
+
675
+ @app.cell(hide_code=True)
676
+ def _(mo):
677
+ mo.md(r"""## Appendix (helper code and functions)""")
678
+ return
679
+
680
+
681
+ @app.cell
682
+ def _():
683
+ import marimo as mo
684
+ return (mo,)
685
+
686
+
687
+ @app.cell(hide_code=True)
688
+ def _():
689
+ from wigglystuff import TangleSlider
690
+ return (TangleSlider,)
691
+
692
+
693
+ @app.cell(hide_code=True)
694
+ def _():
695
+ # Import libraries
696
+ import numpy as np
697
+ import matplotlib.pyplot as plt
698
+ from scipy import stats
699
+ import io
700
+ import base64
701
+ import random
702
+ import time
703
+ import plotly.graph_objects as go
704
+ import plotly.io as pio
705
+ return base64, go, io, np, pio, plt, random, stats, time
706
+
707
+
708
+ @app.cell(hide_code=True)
709
+ def _(base64, io):
710
+ from matplotlib.figure import Figure
711
+
712
+ # Helper function to convert matplotlib figures to images
713
+ def fig_to_image(fig):
714
+ buf = io.BytesIO()
715
+ fig.savefig(buf, format='png', bbox_inches='tight')
716
+ buf.seek(0)
717
+ img_str = base64.b64encode(buf.getvalue()).decode('utf-8')
718
+ return f"data:image/png;base64,{img_str}"
719
+ return Figure, fig_to_image
720
+
721
+
722
+ @app.cell(hide_code=True)
723
+ def _(np, plt, stats):
724
+ def create_dice_game_visualization():
725
+ """Create a visualization for the dice game example."""
726
+ # Parameters
727
+ n_dice = 10
728
+ dice_values = np.arange(1, 7) # 1 to 6
729
+
730
+ # Theoretical values
731
+ single_die_mean = np.mean(dice_values) # 3.5
732
+ single_die_var = np.var(dice_values) # 35/12
733
+
734
+ # Sum distribution parameters
735
+ sum_mean = n_dice * single_die_mean
736
+ sum_var = n_dice * single_die_var
737
+ sum_std = np.sqrt(sum_var)
738
+
739
+ # Possible outcomes for the sum of 10 dice
740
+ min_sum = n_dice * min(dice_values) # 10
741
+ max_sum = n_dice * max(dice_values) # 60
742
+ sum_values = np.arange(min_sum, max_sum + 1)
743
+
744
+ # Create figure
745
+ fig, ax = plt.subplots(figsize=(10, 6))
746
+
747
+ # Calculate PMF through convolution
748
+ # For one die
749
+ single_pmf = np.ones(6) / 6
750
+
751
+ sum_pmf = single_pmf.copy()
752
+ for _ in range(n_dice - 1):
753
+ sum_pmf = np.convolve(sum_pmf, single_pmf)
754
+
755
+ # Plot the PMF
756
+ ax.bar(sum_values, sum_pmf, alpha=0.7, color='royalblue', label='Exact PMF')
757
+
758
+ # Normal approximation
759
+ x = np.linspace(min_sum - 5, max_sum + 5, 1000)
760
+ y = stats.norm.pdf(x, sum_mean, sum_std)
761
+ ax.plot(x, y, 'r-', linewidth=2, label='Normal Approximation')
762
+
763
+ # Win conditions (x ≤ 25 or x ≥ 45)
764
+ win_region_left = sum_values <= 25
765
+ win_region_right = sum_values >= 45
766
+
767
+ # Shade win regions
768
+ ax.bar(sum_values[win_region_left], sum_pmf[win_region_left],
769
+ color='darkorange', alpha=0.7, label='Win Region')
770
+ ax.bar(sum_values[win_region_right], sum_pmf[win_region_right],
771
+ color='darkorange', alpha=0.7)
772
+
773
+ # Calculate win probability
774
+ win_prob = np.sum(sum_pmf[win_region_left]) + np.sum(sum_pmf[win_region_right])
775
+
776
+ # Add vertical lines for critical values
777
+ ax.axvline(x=25.5, color='red', linestyle='--', linewidth=1.5, label='Critical Points')
778
+ ax.axvline(x=44.5, color='red', linestyle='--', linewidth=1.5)
779
+
780
+ # Add mean line
781
+ ax.axvline(x=sum_mean, color='green', linestyle='--', linewidth=1.5,
782
+ label=f'Mean = {sum_mean}')
783
+
784
+ # Text box with relevant information
785
+ textstr = '\n'.join((
786
+ f'Number of dice: {n_dice}',
787
+ f'Sum Mean: {sum_mean}',
788
+ f'Sum Std Dev: {sum_std:.2f}',
789
+ f'Win Probability: {win_prob:.4f}',
790
+ f'CLT Approximation: {0.078:.4f}'
791
+ ))
792
+ props = dict(boxstyle='round', facecolor='wheat', alpha=0.5)
793
+ ax.text(0.05, 0.95, textstr, transform=ax.transAxes, fontsize=10,
794
+ verticalalignment='top', bbox=props)
795
+
796
+ # Formatting
797
+ ax.set_xlabel('Sum of 10 Dice')
798
+ ax.set_ylabel('Probability')
799
+ ax.set_title('Central Limit Theorem: Dice Game Example')
800
+ ax.legend()
801
+ ax.grid(alpha=0.3)
802
+
803
+ plt.tight_layout()
804
+ plt.gca()
805
+ return fig
806
+ return (create_dice_game_visualization,)
807
+
808
+
809
+ @app.cell(hide_code=True)
810
+ def _(np, plt):
811
+ def create_algorithm_runtime_visualization():
812
+ """Create a visualization for the algorithm runtime example."""
813
+ # Parameters
814
+ variance = 4 # σ² = 4 sec²
815
+ std_dev = np.sqrt(variance) # σ = 2 sec
816
+ confidence_level = 0.95
817
+ z_score = 1.96 # for 95% confidence
818
+ target_error = 0.5 # ±0.5 seconds
819
+
820
+ # Calculate n needed for desired precision
821
+ n_required = int(np.ceil((z_score * std_dev / target_error) ** 2)) # ≈ 62
822
+
823
+ n_values = np.arange(1, 100)
824
+
825
+ # standard error
826
+ standard_errors = std_dev / np.sqrt(n_values)
827
+
828
+ # margin of error
829
+ margins_of_error = z_score * standard_errors
830
+
831
+ # Create figure
832
+ fig, ax = plt.subplots(figsize=(10, 6))
833
+
834
+ # standard error vs sample size plot
835
+ ax.plot(n_values, standard_errors, 'b-', linewidth=2, label='Standard Error of Mean')
836
+
837
+ # Plot margin of error vs sample size
838
+ ax.plot(n_values, margins_of_error, 'r--', linewidth=2,
839
+ label=f'{confidence_level*100}% Margin of Error')
840
+
841
+ ax.axvline(x=n_required, color='green', linestyle='-', linewidth=1.5,
842
+ label=f'Required n = {n_required}')
843
+
844
+ ax.axhline(y=target_error, color='purple', linestyle='--', linewidth=1.5,
845
+ label=f'Target Error = ±{target_error} sec')
846
+
847
+ # Shade the region below target error
848
+ ax.fill_between(n_values, 0, target_error, alpha=0.2, color='green')
849
+
850
+ # intersection point
851
+ ax.plot(n_required, target_error, 'ro', markersize=8)
852
+ ax.annotate(f'({n_required}, {target_error} sec)',
853
+ xy=(n_required, target_error),
854
+ xytext=(n_required + 5, target_error + 0.1),
855
+ arrowprops=dict(facecolor='black', shrink=0.05, width=1))
856
+
857
+ # Text box with appropriate information
858
+ textstr = '\n'.join((
859
+ f'Algorithm Variance: {variance} sec²',
860
+ f'Standard Deviation: {std_dev} sec',
861
+ f'Confidence Level: {confidence_level*100}%',
862
+ f'Z-score: {z_score}',
863
+ f'Target Error: ±{target_error} sec',
864
+ f'Required Sample Size: {n_required}'
865
+ ))
866
+ props = dict(boxstyle='round', facecolor='wheat', alpha=0.5)
867
+ ax.text(0.05, 0.95, textstr, transform=ax.transAxes, fontsize=10,
868
+ verticalalignment='top', bbox=props)
869
+
870
+ # Formatting
871
+ ax.set_xlabel('Sample Size (n)')
872
+ ax.set_ylabel('Error (seconds)')
873
+ ax.set_title('Sample Size Determination for Algorithm Runtime Estimation')
874
+ ax.set_xlim(0, 100)
875
+ ax.set_ylim(0, 2)
876
+ ax.legend()
877
+ ax.grid(alpha=0.3)
878
+
879
+ plt.tight_layout()
880
+ return fig
881
+ return (create_algorithm_runtime_visualization,)
882
+
883
+
884
+ @app.cell(hide_code=True)
885
+ def _(mo):
886
+ mo.md(
887
+ r"""
888
+ ## Summary
889
+
890
+ The Central Limit Theorem is truly one of the most remarkable ideas in all of statistics. It tells us that when we add up many independent random variables, their sum will follow a normal distribution, regardless of what the original distributions looked like. This is why we see normal distributions so often in real life – many natural phenomena are the result of numerous small, independent factors adding up.
891
+
892
+ What makes the CLT so powerful is its universality. Whether we're working with dice rolls, measurement errors, or stock market returns, as long as we have enough independent samples, their average or sum will be approximately normal. For sums, the distribution will be $\mathcal{N}(n\mu, n\sigma^2)$, and for averages, it's $\mathcal{N}(\mu, \frac{\sigma^2}{n})$.
893
+
894
+ The CLT gives us the foundation for confidence intervals, hypothesis testing, and many other statistical tools. Without it, we'd have a much harder time making sense of data when we don't know the underlying population distribution. Just remember that if you're working with discrete distributions, you'll need to apply a continuity correction to get more accurate results.
895
+
896
+ Next time you see a normal distribution in data, think about the Central Limit Theorem – it might be the reason behind that familiar bell curve!
897
+ """
898
+ )
899
+ return
900
+
901
+
902
+ @app.cell(hide_code=True)
903
+ def _(mo):
904
+ # controls for the interactive explorer
905
+ distribution_type = mo.ui.dropdown(
906
+ options=["uniform", "exponential", "binomial", "poisson"],
907
+ value="uniform",
908
+ label="Distribution Type"
909
+ )
910
+
911
+ sample_size = mo.ui.slider(
912
+ start =1,
913
+ stop =100,
914
+ step=1,
915
+ value=30,
916
+ label="Sample Size (n)"
917
+ )
918
+
919
+ sim_count_slider = mo.ui.slider(
920
+ start =100,
921
+ stop =10000,
922
+ step=100,
923
+ value=1000,
924
+ label="Number of Simulations"
925
+ )
926
+
927
+ run_explorer_button = mo.ui.run_button(label="Run Simulation", kind="warn")
928
+
929
+ controls = mo.hstack([
930
+ mo.vstack([distribution_type, sample_size, sim_count_slider]),
931
+ run_explorer_button
932
+ ], justify='space-around')
933
+
934
+ return (
935
+ controls,
936
+ distribution_type,
937
+ run_explorer_button,
938
+ sample_size,
939
+ sim_count_slider,
940
+ )
941
+
942
+
943
+ if __name__ == "__main__":
944
+ app.run()