Skip to content

概率论与统计 ​

容斥、条件概率、贝叶斯树、期望与方差、大数定律、中心极限、泊松、3σ 法则、蒙特卡洛 π、随机游走与假设检验。

本页 12 条示例。代码引自 corpus/examples/math-prob/,截图为真机渲染的终态。

概率的加法公式(容斥):P(A∪B) = P(A) + P(B) - P(A∩B) ​

样本空间 Ω 里两个半透明矩形事件 A、B 相互交叠,重叠区由两层填色混出中间色;A∩B 用红色描边框单独点亮并标注字母,再回到公式里说明它刚好是被重复计入的那一份。

概率的加法公式(容斥):P(A∪B) = P(A) + P(B) - P(A∩B)

py
"""概率的加法公式(容斥):P(A∪B) = P(A) + P(B) - P(A∩B)

@expect 样本空间 Ω 里两个半透明矩形事件 A、B 相互交叠,重叠区由两层填色混出中间色;A∩B 用红色描边框单独点亮并标注字母,再回到公式里说明它刚好是被重复计入的那一份。
@note 讲解要点:容斥公式的「减一次交集」不是凑出来的修正项——P(A)+P(B) 把 A∩B 里的概率计了两遍,减掉它才算不重不漏。
"""

from manim import *


class InclusionExclusionScene(Scene):
    def construct(self):
        space = Rectangle(width=8.2, height=3.6, color=GRAY_B).move_to([0, 0.25, 0])
        space.set_fill(GRAY_E, opacity=0.35)

        a_box = Rectangle(width=4.6, height=2.6, color=BLUE).move_to([-1.5, 0.25, 0])
        b_box = Rectangle(width=4.6, height=2.6, color=YELLOW).move_to([1.5, 0.25, 0])
        inter = Rectangle(width=1.6, height=2.6, color=RED).move_to([0, 0.25, 0])
        a_box.set_fill(BLUE, opacity=0.35)
        b_box.set_fill(YELLOW, opacity=0.35)
        inter = Rectangle(width=1.6, height=2.6, color=RED, stroke_width=3).move_to([0, 0.25, 0])

        label_omega = Text("Ω 样本空间", font_size=24, color=GRAY_A).next_to(space, UL, buff=0.14)
        label_a = Text("A", font_size=30, color=BLUE).move_to([-3.2, 1.25, 0])
        label_b = Text("B", font_size=30, color=YELLOW).move_to([3.2, 1.25, 0])
        label_inter = Text("A∩B", font_size=26, color=RED).move_to([0, -0.55, 0])

        formula = MathTex(r"P(A\cup B) = P(A) + P(B) - P(A\cap B)", font_size=44).to_edge(UP, buff=0.3)
        caption = Text(
            "P(A)+P(B) 把中间那块算了两遍,所以减一次",
            font_size=26,
        ).to_edge(DOWN, buff=0.4)

        self.play(Create(space), Write(label_omega), run_time=0.8)
        self.play(Write(formula), run_time=0.8)
        self.play(Create(a_box), Create(b_box), Write(label_a), Write(label_b), run_time=1.0)
        self.play(Create(inter), Write(label_inter), run_time=0.6)
        self.play(Circumscribe(inter, color=WHITE, stroke_width=3), run_time=0.9)
        self.play(Write(caption), run_time=0.7)
        self.wait(0.4)

条件概率:换一个样本空间重新归一化 ​

事件 A 被 B 的边界切成两半,先点亮落在 B 里的那一半;随后 B 之外的部分整体压暗,只剩 B 内部重新按比例摊开。

条件概率:换一个样本空间重新归一化

py
"""条件概率:换一个样本空间重新归一化

@expect 事件 A 被 B 的边界切成两半,先点亮落在 B 里的那一半;随后 B 之外的部分整体压暗,只剩 B 内部重新按比例摊开。
@note 讲解要点:P(A|B) 的分母从 Ω 换成了 B——不是给 A 打折扣,而是「已知 B 发生」之后把概率全部压回 B 内部重新分配。
"""

from manim import *


class ConditionalProbabilityScene(Scene):
    def construct(self):
        omega = Rectangle(width=5.0, height=5.0, color=GRAY_B).move_to([0, -0.5, 0])
        omega.set_fill(GRAY_E, opacity=0.3)

        b_box = Rectangle(width=3.0, height=3.0, color=YELLOW).move_to([-1.0, -1.5, 0])
        b_box.set_fill(YELLOW, opacity=0.22)

        a_inside = Rectangle(width=2.0, height=2.0, color=RED).move_to([-0.5, -1.0, 0])
        a_outside = Rectangle(width=1.0, height=2.0, color=BLUE).move_to([1.0, -1.0, 0])
        a_inside.set_fill(RED, opacity=0.6)
        a_outside.set_fill(BLUE, opacity=0.6)

        label_a = Text("A", font_size=28, color=BLUE).move_to([1.9, 0.1, 0])
        label_b = Text("B", font_size=28, color=YELLOW).move_to([-2.2, 0.1, 0])
        label_ab = MathTex(r"A\cap B", font_size=36, color=WHITE).move_to([-0.5, -1.0, 0])

        formula = MathTex(
            r"P(A\mid B) = \frac{P(A\cap B)}{P(B)} = \frac{0.16}{0.36} \approx 0.44",
            font_size=40,
        ).to_edge(UP, buff=0.35)

        self.play(Create(omega), run_time=0.6)
        self.play(Write(formula), run_time=0.8)
        self.play(Create(a_outside), Create(a_inside), Write(label_a), run_time=0.8)
        self.play(Create(b_box), Write(label_b), run_time=0.7)
        self.play(Write(label_ab), Indicate(a_inside, color=WHITE, scale_factor=1.05), run_time=0.8)
        self.play(
            a_outside.animate.set_opacity(0.12),
            a_inside.animate.set_opacity(1.0),
            omega.animate.set_opacity(0.25),
            b_box.animate.set_opacity(1.0),
            label_a.animate.set_opacity(0.15),
            run_time=1.0,
        )
        self.wait(0.4)

贝叶斯公式:一棵树上把先验和似然合成后验 ​

左侧两层分叉的树把「患病 1% × 检出 99%」和「健康 99% × 误报 5%」两条路分别铺开;右侧列出两条路的联合概率,绿色那条阳性总概率是分母,红色那条占比就是后验。

贝叶斯公式:一棵树上把先验和似然合成后验

py
"""贝叶斯公式:一棵树上把先验和似然合成后验

@expect 左侧两层分叉的树把「患病 1% × 检出 99%」和「健康 99% × 误报 5%」两条路分别铺开;右侧列出两条路的联合概率,绿色那条阳性总概率是分母,红色那条占比就是后验。
@note 讲解要点:阳性结果里真患病只占约 1/6——因为健康人基数大,哪怕误报率只有 5%,误报的绝对数量也远大于真阳性。这就是「基率」被忽略的代价。
"""

from manim import *

ROOT = [-5.9, 0, 0]
LV2_SICK = [-3.6, 1.4, 0]
LV2_WELL = [-3.6, -1.4, 0]
LV3_POS_SICK = [-1.4, 2.25, 0]
LV3_NEG_SICK = [-1.4, 0.55, 0]
LV3_POS_WELL = [-1.4, -0.55, 0]
LV3_NEG_WELL = [-1.4, -2.25, 0]


class BayesTreeScene(Scene):
    def construct(self):
        branch = lambda a, b, c=GRAY_B: Line(a, b, color=c, stroke_width=2.5)

        trunk = VGroup(branch(ROOT, LV2_SICK, BLUE), branch(ROOT, LV2_WELL, YELLOW))
        upper = VGroup(branch(LV2_SICK, LV3_POS_SICK, RED), branch(LV2_SICK, LV3_NEG_SICK))
        lower = VGroup(branch(LV2_WELL, LV3_POS_WELL, RED), branch(LV2_WELL, LV3_NEG_WELL))

        root_dot = Dot(ROOT, radius=0.09, color=WHITE)
        level2_dots = VGroup(
            Dot(LV2_SICK, radius=0.09, color=BLUE),
            Dot(LV2_WELL, radius=0.09, color=YELLOW),
        )
        leaves = VGroup(
            Dot(LV3_POS_SICK, radius=0.09, color=RED),
            Dot(LV3_NEG_SICK, radius=0.09, color=GRAY_B),
            Dot(LV3_POS_WELL, radius=0.09, color=RED),
            Dot(LV3_NEG_WELL, radius=0.09, color=GRAY_B),
        )

        head_labels = VGroup(
            Text("全体", font_size=24).move_to([-6.5, 0, 0]),
            Text("患病 1%", font_size=24, color=BLUE).move_to([-4.75, 1.0, 0]),
            Text("健康 99%", font_size=24, color=YELLOW).move_to([-4.75, -1.0, 0]),
        )
        rate_labels = VGroup(
            Text("检出 99%", font_size=22, color=RED).move_to([-2.6, 2.02, 0]),
            Text("漏检 1%", font_size=22, color=GRAY_A).move_to([-2.6, 0.78, 0]),
            Text("误报 5%", font_size=22, color=RED).move_to([-2.6, -0.78, 0]),
            Text("排除 95%", font_size=22, color=GRAY_A).move_to([-2.6, -2.02, 0]),
        )
        leaf_labels = VGroup(
            Text("阳性", font_size=26, color=RED).move_to([-0.95, 2.25, 0]),
            Text("阴性", font_size=26, color=GRAY_B).move_to([-0.95, 0.55, 0]),
            Text("阳性", font_size=26, color=RED).move_to([-0.95, -0.55, 0]),
            Text("阴性", font_size=26, color=GRAY_B).move_to([-0.95, -2.25, 0]),
        )

        line_sick_pos = Text("P(病 ∩ 阳) = 0.01 × 0.99 = 0.0099", font_size=24, color=BLUE)
        line_sick_pos.move_to([3.6, 2.25, 0])
        line_well_pos = Text("P(健康 ∩ 阳) = 0.99 × 0.05 = 0.0495", font_size=24, color=YELLOW)
        line_well_pos.move_to([3.6, 1.05, 0])
        line_total = Text("P(阳) = 0.0099 + 0.0495 = 0.0594", font_size=24, color=GREEN)
        line_total.move_to([3.6, -0.5, 0])
        posterior = Text("P(病 | 阳) = 0.0099 / 0.0594 ≈ 0.167", font_size=30, color=RED)
        posterior.move_to([3.6, -1.9, 0])

        caption = Text("阳性里真患病只有约 1/6——基数大的误报盖过了真阳性", font_size=24, color=GRAY_A)
        caption.to_edge(DOWN, buff=0.35)

        self.play(Create(trunk), FadeIn(root_dot), run_time=0.8)
        self.play(Write(head_labels), FadeIn(level2_dots), run_time=0.8)
        self.play(Create(upper), Create(lower), FadeIn(leaves), run_time=1.0)
        self.play(Write(rate_labels), run_time=0.9)
        self.play(Write(leaf_labels), run_time=0.8)
        self.play(Write(line_sick_pos), Write(line_well_pos), run_time=0.9)
        self.play(Write(line_total), run_time=0.7)
        self.play(Write(posterior), run_time=0.9)
        self.play(Write(caption), run_time=0.8)
        self.wait(0.4)

期望:概率分布的「重心」,也是这根杠杆的支点 ​

六个取值位置上立着高度正比于概率的方块,杠杆在正中间找到平衡——支点恰好落在加权平均 μ=3.6 处,而不是算术中心 3.5。

期望:概率分布的「重心」,也是这根杠杆的支点

py
"""期望:概率分布的「重心」,也是这根杠杆的支点

@expect 六个取值位置上立着高度正比于概率的方块,杠杆在正中间找到平衡——支点恰好落在加权平均 μ=3.6 处,而不是算术中心 3.5。
@note 讲解要点:期望就是「按概率加权后的平均位置」。概率大的取值把支点拉向自己,和物理杠杆完全同构——这也是期望才叫「均值」的原因。
"""

from manim import *


class ExpectationBalanceScene(Scene):
    def construct(self):
        faces = [1, 2, 3, 4, 5, 6]
        weights = [0.10, 0.15, 0.20, 0.25, 0.20, 0.10]
        mean = sum(f * w for f, w in zip(faces, weights))

        x_at = lambda face: -4.0 + (face - 1) * 1.6
        beam = Line([-5.2, 0, 0], [5.2, 0, 0], color=GRAY_B, stroke_width=6)

        blocks = VGroup()
        for face, w in zip(faces, weights):
            block = Rectangle(width=0.85, height=w * 9.0, color=BLUE)
            block.set_fill(BLUE, opacity=0.65)
            block.move_to([x_at(face), w * 9.0 / 2, 0])
            blocks.add(block)

        face_labels = VGroup()
        for face in faces:
            face_labels.add(
                Text(str(face), font_size=28, color=YELLOW).move_to([x_at(face), -0.45, 0])
            )

        weight_labels = VGroup()
        for face, w in zip(faces, weights):
            weight_labels.add(
                Text(f"{w:.2f}", font_size=20, color=GRAY_A).move_to([x_at(face), w * 9.0 + 0.3, 0])
            )

        pivot_x = x_at(mean)
        fulcrum = Triangle(color=RED).scale(0.45).move_to([pivot_x, -0.32, 0])
        pivot_label = Text("支点 = 期望 μ = 3.6", font_size=26, color=RED)
        pivot_label.next_to(fulcrum, DOWN, buff=0.35)

        # MathTex 定位用绝对坐标:to_edge 要读包围盒,包围盒由 KaTeX 异步量出,量出前是 NaN(时隐时现)
        formula = MathTex(r"\mu \;=\; \sum_i x_i\, p_i \;=\; 3.6", font_size=48).move_to([0, 3.3, 0])

        self.play(Write(formula), run_time=1.0)
        self.play(Create(beam), run_time=0.5)
        self.play(LaggedStart(*[FadeIn(b) for b in blocks], lag_ratio=0.1), run_time=1.4)
        self.play(Write(face_labels), Write(weight_labels), run_time=0.8)
        self.play(FadeIn(fulcrum), Write(pivot_label), run_time=0.8)
        self.play(Indicate(blocks[3], color=RED, scale_factor=1.06), run_time=0.6)
        self.wait(0.4)

方差刻画离散程度:同期望,不同「撒得开」 ​

上下一共两组数据共享同一条数轴和同一个均值刻度,但一组的点挤成一撮、另一组散得很开;两条两端带短竖线端点标记的横线标出各自的极差,视觉差距直接对应方差 2 与 200。

方差刻画离散程度:同期望,不同「撒得开」

py
"""方差刻画离散程度:同期望,不同「撒得开」

@expect 上下一共两组数据共享同一条数轴和同一个均值刻度,但一组的点挤成一撮、另一组散得很开;两条两端带短竖线端点标记的横线标出各自的极差,视觉差距直接对应方差 2 与 200。
@note 讲解要点:均值只告诉你「重心在哪」,完全说不出「有多散」——两组数据 μ 都是 50,却是完全不同的两个分布。方差就是把这个「散」量化出来的量。
"""

from manim import *


class VarianceSpreadScene(Scene):
    def construct(self):
        group_a = [48, 49, 50, 51, 52]
        group_b = [30, 40, 50, 60, 70]
        x_of = lambda v: -5.0 + (v - 20) / 6.0

        axis = NumberLine(
            x_range=[20, 80, 10],
            length=10.0,
            include_numbers=True,
            color=GRAY_B,
        )
        mean_line = Line([0, -2.4, 0], [0, 2.4, 0], color=RED, stroke_width=3)

        dots_a = VGroup(*[Dot([x_of(v), 1.7, 0], radius=0.11, color=BLUE) for v in group_a])
        dots_b = VGroup(*[Dot([x_of(v), -1.7, 0], radius=0.11, color=YELLOW) for v in group_b])

        def span_line(x1, x2, y, color):
            bar = Line([x1, y, 0], [x2, y, 0], color=color, stroke_width=4)
            cap_l = Line([x1, y - 0.14, 0], [x1, y + 0.14, 0], color=color, stroke_width=4)
            cap_r = Line([x2, y - 0.14, 0], [x2, y + 0.14, 0], color=color, stroke_width=4)
            return VGroup(bar, cap_l, cap_r)

        span_a = span_line(x_of(48), x_of(52), 2.5, BLUE)
        span_b = span_line(x_of(30), x_of(70), -2.5, YELLOW)

        txt = lambda s, c=WHITE, fs=26: Text(s, font_size=fs, color=c)
        labels = VGroup(
            txt("μ = 50(同一个重心)", RED, 24).next_to(mean_line, UP, buff=0.1).shift(RIGHT * 2.2),
            txt("A:48~52,σ² = 2", BLUE, 24).move_to([x_of(48) + 0.2, 3.1, 0]),
            txt("B:30~70,σ² = 200", YELLOW, 24).move_to([x_of(70) - 1.0, -3.1, 0]),
        )

        title = txt("期望只看重心,方差才看「散得多开」", GRAY_A, 28).to_edge(UP, buff=0.3)

        self.play(Write(title), run_time=0.7)
        self.play(Create(axis), run_time=0.7)
        self.play(FadeIn(dots_a), Create(span_a), Write(labels[1]), run_time=1.0)
        self.play(Create(mean_line), Write(labels[0]), run_time=0.8)
        self.play(FadeIn(dots_b), Create(span_b), Write(labels[2]), run_time=1.0)
        self.wait(0.4)

大数定律:频率随试验次数增加而贴向真实概率 ​

一条从剧烈上下抖动开始、越往右摆幅越小的折线,逐渐收拢到 y=0.5 那条理论概率的虚线上——收敛是「以概率 1」的,不是每一步都单调靠近。

大数定律:频率随试验次数增加而贴向真实概率

py
"""大数定律:频率随试验次数增加而贴向真实概率

@expect 一条从剧烈上下抖动开始、越往右摆幅越小的折线,逐渐收拢到 y=0.5 那条理论概率的虚线上——收敛是「以概率 1」的,不是每一步都单调靠近。
@note 讲解要点:折线在左侧跳得厉害并不代表不公平;大数定律说的是 n 趋于无穷时频率依概率收敛到 p。前几步的偏差会被庞大的分母稀释掉。
"""

from manim import *
import numpy as np


class LawOfLargeNumbersScene(Scene):
    def construct(self):
        rng = np.random.default_rng(2024)
        n_max = 400
        flips = (rng.random(n_max) < 0.5).astype(float)
        freq = np.cumsum(flips) / np.arange(1, n_max + 1)

        ax = Axes(
            x_range=[0, 400, 100],
            y_range=[0, 1, 0.25],
            x_length=9.5,
            y_length=4.2,
            tips=False,
            axis_config={"color": GRAY_B},
        )
        ax.shift(DOWN * 0.9)

        true_line = DashedLine(ax.c2p(0, 0.5), ax.c2p(400, 0.5), color=RED)
        true_label = Text("真实概率 p = 0.5", font_size=26, color=RED)
        true_label.next_to(true_line, UP, buff=0.12).align_to(true_line, RIGHT)

        step = 5
        pts = [ax.c2p(n, float(freq[n - 1])) for n in range(step, n_max + 1, step)]
        path = VGroup(
            *[Line(pts[i], pts[i + 1], color=YELLOW, stroke_width=2.5) for i in range(len(pts) - 1)]
        )

        start_mark = Dot(pts[0], radius=0.07, color=BLUE)
        end_mark = Dot(pts[-1], radius=0.09, color=GREEN)

        title = Text("大数定律:n 越大,频率越贴住 p", font_size=30).to_edge(UP, buff=0.35)
        caption = Text("左边抖得厉害只是分母小,不是硬币有问题", font_size=26, color=GRAY_A)
        caption.to_edge(DOWN, buff=0.35)

        self.play(Create(ax), Write(title), run_time=1.0)
        self.play(Create(true_line), Write(true_label), run_time=0.6)
        self.play(Create(path), FadeIn(start_mark), run_time=2.4, rate_func=linear)
        self.play(FadeIn(end_mark), run_time=0.4)
        self.play(Write(caption), run_time=0.7)
        self.wait(0.4)

中心极限定理:独立同分布求和,分布形状必然向正态靠拢 ​

三个并排直方图,从左到右分别是一颗骰子(平的)、两颗之和(三角形)、四颗之和(明显钟形);右侧两张的柱子朝中间堆、两端塌下去,且被归一化成同高以便比较形状。

中心极限定理:独立同分布求和,分布形状必然向正态靠拢

py
"""中心极限定理:独立同分布求和,分布形状必然向正态靠拢

@expect 三个并排直方图,从左到右分别是一颗骰子(平的)、两颗之和(三角形)、四颗之和(明显钟形);右侧两张的柱子朝中间堆、两端塌下去,且被归一化成同高以便比较形状。
@note 讲解要点:单颗骰子是完全均匀的,一点都不「正态」;但只要把若干独立同分布的变量加起来,中心就会长出钟形——不管原来是什么分布。这是正态分布无处不在的根源。
"""

from manim import *
import numpy as np


class CentralLimitScene(Scene):
    def construct(self):
        rng = np.random.default_rng(2026)
        samples = 20000

        def heights_for(k):
            rolls = rng.integers(1, 7, size=(samples, k)).sum(axis=1).tolist()
            counts = [0] * (6 * k - k + 1)
            for value in rolls:
                counts[value - k] += 1
            peak = max(counts)
            return [c / peak for c in counts]

        def make_panel(k, center, color):
            hs = heights_for(k)
            span = 3.5
            slot = span / len(hs)
            bars = VGroup()
            for i, h in enumerate(hs):
                bar = Rectangle(width=slot * 0.82, height=float(h) * 2.0, color=color)
                bar.set_fill(color, opacity=0.7)
                bar.move_to([center - span / 2 + slot * (i + 0.5), -1.4 + float(h), 0])
                bars.add(bar)
            return bars

        panel_cfg = [(1, -4.6, BLUE, "1 颗骰子"), (2, 0.0, GREEN, "2 颗相加"), (4, 4.6, YELLOW, "4 颗相加")]
        bars_all = VGroup()
        titles = VGroup()
        for k, center, color, name in panel_cfg:
            bars_all.add(make_panel(k, center, color))
            titles.add(Text(name, font_size=26, color=color).move_to([center, 0.95, 0]))

        baseline = VGroup(
            *[Line([c - 1.75, -1.4, 0], [c + 1.75, -1.4, 0], color=GRAY_B, stroke_width=2) for _, c, _, _ in panel_cfg]
        )

        title = Text("中心极限定理:求和之后,形状自己长成钟形", font_size=30).to_edge(UP, buff=0.35)
        caption = Text(
            "原来均匀 / 三角形都无所谓,加得越多越像正态",
            font_size=26,
            color=GRAY_A,
        ).to_edge(DOWN, buff=0.35)

        self.play(Write(title), Create(baseline), run_time=0.8)
        self.play(LaggedStart(*[FadeIn(p) for p in bars_all], lag_ratio=0.25), run_time=2.0)
        self.play(Write(titles), run_time=0.8)
        self.play(Write(caption), run_time=0.7)
        self.wait(0.4)

泊松分布:稀有事件在时间轴上的到达 ​

一条时间轴上不均匀地散落着若干次到达时刻(事件不能重叠),旁边画出计数 k 的分布柱:k=2、3 附近最高,两端迅速衰减。

泊松分布:稀有事件在时间轴上的到达

py
"""泊松分布:稀有事件在时间轴上的到达

@expect 一条时间轴上不均匀地散落着若干次到达时刻(事件不能重叠),旁边画出计数 k 的分布柱:k=2、3 附近最高,两端迅速衰减。
@note 讲解要点:泊松过程描述「单位时间内随机到达的稀有事件」——到达是独立的、速率恒定,于是计数服从泊松分布,它的期望和方差都等于 λ。
"""

from manim import *
import math
import numpy as np


class PoissonArrivalsScene(Scene):
    def construct(self):
        lam = 2.5
        length = 10.0
        rng = np.random.default_rng(7)

        gaps = rng.exponential(1.0 / lam, size=60)
        arrivals = np.cumsum(gaps)
        # 时间窗 [0, length] 线性映到数轴 [-5, 5]
        arrivals = arrivals[arrivals < length] - 5.0

        timeline = Line([-5.0, 1.5, 0], [5.0, 1.5, 0], color=GRAY_B, stroke_width=4)
        tick_labels = VGroup()
        for i in range(11):
            x = -5.0 + i
            tick_labels.add(Line([x, 1.5, 0], [x, 1.35, 0], color=GRAY_B, stroke_width=2))
            if i % 5 == 0:
                tick_labels.add(Text(str(i), font_size=22, color=GRAY_A).move_to([x, 1.05, 0]))

        drops = VGroup(
            *[
                Line([float(t), 1.5, 0], [float(t), 0.62, 0], color=RED, stroke_width=2.5) for t in arrivals
            ]
        )
        heads = VGroup(*[Dot([float(t), 1.5, 0], radius=0.07, color=RED) for t in arrivals])

        timeline_label = Text(f"单位时间到达次数 λ = {lam} 的事件流", font_size=26, color=RED)
        timeline_label.move_to([0, 2.4, 0])

        ks = list(range(0, 9))
        pmf = [float(np.exp(-lam) * lam ** k / math.factorial(k)) for k in ks]
        peak = max(pmf)
        bars = VGroup()
        labels_k = VGroup()
        for k, p in zip(ks, pmf):
            x = -5.0 + k * 1.25
            bar = Rectangle(width=0.85, height=p / peak * 2.4, color=BLUE)
            bar.set_fill(BLUE, opacity=0.7)
            bar.move_to([x, -2.2 + p / peak * 2.4 / 2, 0])
            bars.add(bar)
            labels_k.add(Text(str(k), font_size=24, color=YELLOW).move_to([x, -2.55, 0]))

        bar_axis = Line([-5.6, -2.2, 0], [5.6, -2.2, 0], color=GRAY_B, stroke_width=2)
        bar_label = Text("计数 k 的分布", font_size=26, color=BLUE).move_to([0, 0.3, 0])

        self.play(Write(timeline_label), Create(timeline), Create(tick_labels), run_time=0.9)
        self.play(LaggedStart(*[Create(d) for d in drops], lag_ratio=0.08), run_time=1.6)
        self.play(FadeIn(heads), run_time=0.5)
        self.play(Create(bar_axis), Write(bar_label), run_time=0.7)
        self.play(LaggedStart(*[FadeIn(b) for b in bars], lag_ratio=0.1), run_time=1.4)
        self.play(Write(labels_k), run_time=0.7)
        self.wait(0.4)

正态分布的 3σ 法则:钟形曲线下的面积怎么分 ​

钟形曲线下方用虚线切出 ±1σ、±2σ、±3σ 三道关口,右侧列出各自覆盖的面积;下方 1000 次抽样的小点密密麻麻挤在中间,落在 ±2.5σ 之外的少数几个被染红。

正态分布的 3σ 法则:钟形曲线下的面积怎么分

py
"""正态分布的 3σ 法则:钟形曲线下的面积怎么分

@expect 钟形曲线下方用虚线切出 ±1σ、±2σ、±3σ 三道关口,右侧列出各自覆盖的面积;下方 1000 次抽样的小点密密麻麻挤在中间,落在 ±2.5σ 之外的少数几个被染红。
@note 讲解要点:68.3% / 95.4% / 99.7% 说的是「概率集中在中心」,不是「值域到此为止」——尾巴永远存在,只是以小概率存在,所以 3σ 外的事件叫「小概率」而不叫「不可能」。
"""

from manim import *
import numpy as np


class NormalSigmaScene(Scene):
    def construct(self):
        rng = np.random.default_rng(31)
        pdf = lambda x: np.exp(-x * x / 2.0) / np.sqrt(2.0 * np.pi)

        ax = Axes(
            x_range=[-4, 4, 1],
            y_range=[0, 0.5, 0.1],
            x_length=11.0,
            y_length=2.4,
            tips=False,
            axis_config={"color": GRAY_B},
        )
        ax.shift(UP * 1.55)

        curve = ax.plot(lambda t: float(pdf(t)), x_range=[-4, 4], color=BLUE)

        gates = VGroup()
        for sigma, color in [(1.0, GREEN), (2.0, YELLOW), (3.0, RED)]:
            for sign in (-1, 1):
                gates.add(
                    DashedLine(
                        ax.c2p(sign * sigma, 0),
                        ax.c2p(sign * sigma, 0.42),
                        color=color,
                        stroke_width=2,
                    )
                )

        label_rows = VGroup(
            Text("±1σ → 68.3%", font_size=24, color=GREEN).move_to([5.55, 0.55, 0]),
            Text("±2σ → 95.4%", font_size=24, color=YELLOW).move_to([5.55, 0.05, 0]),
            Text("±3σ → 99.7%", font_size=24, color=RED).move_to([5.55, -0.45, 0]),
        )

        raw = rng.normal(0.0, 1.0, size=1000)
        ys = rng.uniform(-2.5, -1.35, size=1000)
        inside = VGroup()
        outside = VGroup()
        for value, y in zip(raw, ys):
            # 抽样点的 x 要过 c2p 换算,才和上方曲线同一个比例
            px = ax.c2p(float(value), 0)[0]
            if abs(value) > 2.5:
                outside.add(Dot([px, float(y), 0], radius=0.035, color=RED))
            else:
                inside.add(Dot([px, float(y), 0], radius=0.035, color=TEAL))

        sample_note = Text("1000 次抽样:落在 ±2.5σ 之外的只有极少数", font_size=26, color=GRAY_A)
        sample_note.move_to([0, -0.95, 0])

        title = Text("正态分布的 3σ 法则", font_size=30).to_edge(UP, buff=0.3)
        caption = Text("尾巴不是 0,只是极小——小概率 ≠ 不可能", font_size=26, color=GRAY_A)
        caption.to_edge(DOWN, buff=0.3)

        self.play(Write(title), Create(ax), run_time=0.8)
        self.play(Create(curve), run_time=1.2)
        self.play(Create(gates), Write(label_rows), run_time=1.0)
        self.play(LaggedStart(*[FadeIn(d) for d in inside], lag_ratio=0.002), run_time=1.6)
        self.play(FadeIn(outside), Write(sample_note), run_time=0.8)
        self.play(Write(caption), run_time=0.7)
        self.wait(0.4)

蒙特卡洛估算 π:往正方形里撒点,数落在四分之一圆内的比例 ​

边长为 2 的正方形内含一个四分之一圆,蓝点在正方形里随机撒开:落在圆弧内的染黄、在外面的留蓝;右下角实时显示累计比例乘 4 逼近 3.14。

蒙特卡洛估算 π:往正方形里撒点,数落在四分之一圆内的比例

py
"""蒙特卡洛估算 π:往正方形里撒点,数落在四分之一圆内的比例

@expect 边长为 2 的正方形内含一个四分之一圆,蓝点在正方形里随机撒开:落在圆弧内的染黄、在外面的留蓝;右下角实时显示累计比例乘 4 逼近 3.14。
@note 讲解要点:P(落在圆内) = 圆面积/正方形面积 = π/4,所以 π ≈ 4 × 命中比例。随机模拟的误差按 1/√n 收缩,想把精度翻倍就得撒四倍的点。
"""

from manim import *
import numpy as np


class MonteCarloPiScene(Scene):
    def construct(self):
        rng = np.random.default_rng(1024)
        radius = 2.4
        # 圆心(四分之一圆的圆心在正方形左下角)
        cx, cy = -3.9, -0.5
        x0, y0 = cx + radius, cy + radius

        square = Rectangle(width=2 * radius, height=2 * radius, color=GRAY_B)
        square.move_to([cx, cy, 0])
        # 四分之一圆的半径 = 正方形边长(圆心在左下角,弧端点落在另外两个角上)。
        # Arc 以圆心为原点生成,shift 直接把圆心放到左下角——不能用 move_to(那挪的是外接框中心)
        arc = Arc(radius=2 * radius, start_angle=0, angle=PI / 2, color=GREEN, stroke_width=4)
        arc.shift([cx - radius, cy - radius, 0])

        side_label = Text("边长 2", font_size=22, color=GRAY_A).move_to([cx, cy - radius - 0.35, 0])
        # 标签沿弧的 45° 方向放在弧内,别压在点堆里
        arc_label = Text("半径 2", font_size=22, color=GREEN).move_to(
            [cx - radius + 1.45, cy - radius + 1.45, 0]
        )

        pts = rng.random((1200, 2))
        hits = VGroup()
        misses = VGroup()
        for u, v in pts:
            x = cx - radius + float(u) * 2 * radius
            y = cy - radius + float(v) * 2 * radius
            if u * u + v * v <= 1.0:
                hits.add(Dot([x, y, 0], radius=0.03, color=YELLOW))
            else:
                misses.add(Dot([x, y, 0], radius=0.03, color=BLUE))

        ratio = len(hits) / len(pts)
        estimate = 4.0 * ratio

        formula = MathTex(
            r"\pi \approx 4 \times \frac{N_{\text{in}}}{N_{\text{all}}}",
            font_size=54,
        ).move_to([3.1, 1.6, 0])

        result = Text(f"命中 {len(hits)} / {len(pts)} → π ≈ {estimate:.3f}", font_size=34, color=YELLOW)
        result.move_to([3.1, 0.3, 0])
        truth = Text("真值 π = 3.14159...", font_size=26, color=GRAY_A).move_to([3.1, -0.5, 0])

        error_note = Text("误差按 1/√n 收缩:精度翻倍要点数 ×4", font_size=26, color=GRAY_A)
        error_note.move_to([3.1, -1.7, 0])

        title = Text("蒙特卡洛方法:用随机撒点量出面积比", font_size=30).to_edge(UP, buff=0.3)

        self.play(Write(title), run_time=0.7)
        self.play(Create(square), Create(arc), Write(side_label), Write(arc_label), run_time=0.9)
        self.play(LaggedStart(*[FadeIn(d) for d in hits], lag_ratio=0.01), run_time=1.6)
        self.play(FadeIn(misses), run_time=0.8)
        self.play(Write(formula), run_time=0.8)
        self.play(Write(result), Write(truth), run_time=0.8)
        self.play(Write(error_note), run_time=0.7)
        self.wait(0.4)

随机游走:每一步无记忆,但整体呈现出 √n 的尺度 ​

四条独立的一维随机游走轨迹同时从原点出发,各自上下乱走、互不同步;终点被标出,包围它们的横向包络宽度远小于步数——典型量级是 √n 而不是 n。

随机游走:每一步无记忆,但整体呈现出 √n 的尺度

py
"""随机游走:每一步无记忆,但整体呈现出 √n 的尺度

@expect 四条独立的一维随机游走轨迹同时从原点出发,各自上下乱走、互不同步;终点被标出,包围它们的横向包络宽度远小于步数——典型量级是 √n 而不是 n。
@note 讲解要点:每一步的期望位移是 0,所以游走不会「漂」到某一边;但方差逐步累加,标准差按 √n 增长。这正是「无偏但不确定」的标准图像,也是扩散方程与布朗运动的离散原型。
"""

from manim import *
import numpy as np
import math


class RandomWalkScene(Scene):
    def construct(self):
        rng = np.random.default_rng(1234)
        steps = 300
        colors = [BLUE, YELLOW, GREEN, TEAL]

        ax = Axes(
            x_range=[0, 300, 100],
            y_range=[-30, 30, 15],
            x_length=9.5,
            y_length=4.6,
            tips=False,
            axis_config={"color": GRAY_B},
        )
        ax.shift(DOWN * 0.6)

        zero_line = DashedLine(ax.c2p(0, 0), ax.c2p(300, 0), color=GRAY_B, stroke_width=2)

        # ±√n 参考带:两端绝对坐标(c2p),直观对照「包络宽度 ~ √n 而非 n」
        sqrt_n = math.sqrt(300)
        band_top = DashedLine(ax.c2p(0, sqrt_n), ax.c2p(300, sqrt_n), color=RED, stroke_width=2)
        band_bottom = DashedLine(ax.c2p(0, -sqrt_n), ax.c2p(300, -sqrt_n), color=RED, stroke_width=2)
        band_label = Text("±√n ≈ ±17.3", font_size=24, color=RED).move_to([2.7, 0.98, 0])

        walks = VGroup()
        ends = VGroup()
        legend = VGroup()
        end_pts = []
        for idx, color in enumerate(colors):
            moves = rng.choice([-1.0, 1.0], size=steps)
            path_values = np.concatenate([[0.0], np.cumsum(moves)])
            pts = [ax.c2p(int(i), float(v)) for i, v in enumerate(path_values)]
            walks.add(
                VGroup(*[Line(pts[i], pts[i + 1], color=color, stroke_width=2) for i in range(len(pts) - 1)])
            )
            end_pts.append(pts[-1])
            ends.add(Dot(pts[-1], radius=0.08, color=color))
            legend.add(Text(f"第 {idx + 1} 条", font_size=22, color=color).move_to([4.4, 2.6 - idx * 0.45, 0]))
        # 蓝色终点单独补一颗稍大的点,避免与其他终点重叠后看不见
        blue_end_dot = Dot(end_pts[0], radius=0.11, color=BLUE, stroke_color=WHITE, stroke_width=1.5)

        sigma_note = MathTex(r"\text{std}(S_n) = \sqrt{n} \approx 17.3", font_size=40, color=RED)
        sigma_note.move_to([-3.9, -2.4, 0])
        formula = MathTex(r"S_n = X_1 + X_2 + \cdots + X_n,\quad E[X_i] = 0", font_size=40)
        formula.to_edge(UP, buff=0.35)
        caption = Text("期望停在 0,但幅度按 √n 长——无偏,却越来越说不准", font_size=26, color=GRAY_A)
        caption.to_edge(DOWN, buff=0.3)

        self.play(Write(formula), run_time=0.8)
        self.play(Create(ax), Create(zero_line), run_time=0.9)
        self.play(LaggedStart(*[Create(w) for w in walks], lag_ratio=0.3), run_time=3.0)
        self.play(FadeIn(ends), FadeIn(blue_end_dot), Write(legend), run_time=0.7)
        self.play(Create(band_top), Create(band_bottom), Write(band_label), run_time=0.8)
        self.play(Write(sigma_note), run_time=0.7)
        self.play(Write(caption), run_time=0.7)
        self.wait(0.4)

假设检验的两类错误:α 是弃真,β 是取伪 ​

两条错开的钟形曲线分别代表原假设 H0 与备择假设 H1;H0 右尾被切出红线区(弃真 / 假阳性),H1 左尾被切出黄线区(取伪 / 假阴性),中间那道竖线就是拒绝域的门槛。

假设检验的两类错误:α 是弃真,β 是取伪

py
"""假设检验的两类错误:α 是弃真,β 是取伪

@expect 两条错开的钟形曲线分别代表原假设 H0 与备择假设 H1;H0 右尾被切出红线区(弃真 / 假阳性),H1 左尾被切出黄线区(取伪 / 假阴性),中间那道竖线就是拒绝域的门槛。
@note 讲解要点:门槛往右移,弃真概率 α 变小、但取伪概率 β 变大;两者此消彼长,不可能同时压到最低。只有加大样本量让两条曲线变瘦,才能同时把 α 和 β 都收小。
"""

from manim import *
import numpy as np


class HypothesisTestScene(Scene):
    def construct(self):
        pdf = lambda x, mu: np.exp(-(x - mu) ** 2 / (2 * 0.55 ** 2)) / (0.55 * np.sqrt(2 * np.pi))

        ax = Axes(
            x_range=[-3, 5, 1],
            y_range=[0, 0.8, 0.2],
            x_length=10.5,
            y_length=3.4,
            tips=False,
            axis_config={"color": GRAY_B},
        )
        ax.shift(DOWN * 0.35)

        critical = 1.1
        h0_mu = 0.0
        h1_mu = 2.3

        curve_h0 = ax.plot(lambda t: float(pdf(t, h0_mu)), x_range=[-3, 4.2], color=BLUE)
        curve_h1 = ax.plot(lambda t: float(pdf(t, h1_mu)), x_range=[-1.2, 5], color=GREEN)

        bar_span = (critical + 4.2) - critical
        bar_w = bar_span / 40.0
        alpha_bars = VGroup()
        for i in range(40):
            x = critical + bar_w * (i + 0.5)
            y_low = ax.c2p(0, 0)[1]
            y_high = ax.c2p(0, float(pdf(x, h0_mu)))[1]
            if abs(y_high - y_low) < 1e-6:
                continue
            bar = Rectangle(width=abs(ax.c2p(bar_w, 0)[0] - ax.c2p(0, 0)[0]), height=y_high - y_low, color=RED)
            bar.set_fill(RED, opacity=0.85)
            bar.move_to([ax.c2p(x, 0)[0], (y_low + y_high) / 2, 0])
            alpha_bars.add(bar)

        beta_bars = VGroup()
        for i in range(40):
            x = critical - bar_w * (i + 0.5)
            y_low = ax.c2p(0, 0)[1]
            y_high = ax.c2p(0, float(pdf(x, h1_mu)))[1]
            if abs(y_high - y_low) < 1e-6:
                continue
            bar = Rectangle(width=abs(ax.c2p(bar_w, 0)[0] - ax.c2p(0, 0)[0]), height=y_high - y_low, color=YELLOW)
            bar.set_fill(YELLOW, opacity=0.85)
            bar.move_to([ax.c2p(x, 0)[0], (y_low + y_high) / 2, 0])
            beta_bars.add(bar)

        gate = Line(ax.c2p(critical, 0), ax.c2p(critical, 0.72), color=WHITE, stroke_width=3)
        gate_label = Text("拒绝域门槛", font_size=24, color=WHITE).next_to(gate, UP, buff=0.12)

        labels = VGroup(
            Text("H0 成立时的分布", font_size=26, color=BLUE).move_to([ax.c2p(-2.3, 0)[0], 2.9, 0]),
            Text("H1 成立时的分布", font_size=26, color=GREEN).move_to([ax.c2p(3.4, 0)[0], 2.0, 0]),
            Text("α:弃真(假阳性)", font_size=24, color=RED).move_to([ax.c2p(2.6, 0)[0], 1.0, 0]),
            Text("β:取伪(假阴性)", font_size=24, color=YELLOW).move_to([ax.c2p(-0.7, 0)[0], -2.6, 0]),
        )

        title = Text("两类错误此消彼长:门槛一动,α 与 β 一个减小一个变大", font_size=28)
        title.to_edge(UP, buff=0.3)

        self.play(Write(title), Create(ax), run_time=0.8)
        self.play(Create(curve_h0), Create(curve_h1), run_time=1.4)
        self.play(Write(labels[0]), Write(labels[1]), run_time=0.7)
        self.play(Create(gate), Write(gate_label), run_time=0.6)
        self.play(FadeIn(alpha_bars), Write(labels[2]), run_time=0.9)
        self.play(FadeIn(beta_bars), Write(labels[3]), run_time=0.9)
        self.wait(0.4)

MIT licensed · Manim 与 Pyodide 各依其自身许可(见「安装」)