
如果你在一个圆里随手画一条弦它比圆内接等边三角形的边长更长的概率是多少第一次见到这个问题的人十有八九会以为答案只有一个。但贝特朗悖论给出的结论是概率可能是 1/2、1/3 甚至 1/4而且每一种算法都逻辑自洽。当年我第一次看到这个结论时第一反应是“概率论是不是出了问题”后来亲手把这些方法画成图、跑完模拟才真正明白问题出在“随机”这个词本身。这篇文章就用“贝特朗悖论作图”这条线把三种随机取弦方法从头到尾拆一遍先讲清楚悖论产生的数学根源再给出三种方法的几何直觉和推导然后用 Python 把三种方法画出来、用蒙特卡洛模拟把概率跑出来。整个过程不需要高深的数学基础只要会一点 Python 和初中几何就能完全看懂并复现。无论你是学概率论的学生、做数据分析的从业者还是对数学可视化感兴趣的开发者这篇内容都能给你一个能直接跑的完整方案并且从根上解释清楚“为什么答案不唯一”。1. 贝特朗悖论到底在说什么先把这个问题的背景和本质交代清楚。贝特朗悖论不是一道“脑筋急转弯”它实际上是在挑战概率论的一个根基问题当我们在一个连续空间里说“等可能地随机选择”时这句话到底意味着什么1.1 一个问题三种答案贝特朗在 1889 年出版的概率论著作中提出了这样一个问题给定一个圆在圆内随机取一条弦这条弦的长度大于圆内接等边三角形边长的概率是多少等边三角形的边长很好算。如果圆的半径为 R内接等边三角形的边长是 √3·R。所以问题就变成随机弦的长度 L 大于 √3·R 的概率是多少。下面分别给出三种自然的解法。解法一固定端点法。在圆周上随机取两个点连接成弦。固定其中一个端点另一个端点在圆周上均匀分布。能构成“长弦”的弧段占圆周的 1/3所以概率是 1/3。解法二随机半径法。先在圆内随机选一条半径方向再在这条半径上均匀随机取一个点作为弦的中点然后作垂直于半径的弦。当弦心距小于 R/2 时弦长大于 √3·R。半径上“小于 R/2”的区域占比是 1/2所以概率是 1/2。解法三随机中点法。直接在圆内均匀随机取一个点作为弦的中点然后作以该点为中点的弦。同样根据弦心距小于 R/2 的条件但在圆内均匀取点时半径为 R/2 的小圆面积占大圆面积的 1/4所以概率是 1/4。同一个问题三种看起来都很“自然”的随机方式得到三个不同的答案。这就是贝特朗悖论。1.2 随机这个说法藏着一个大坑为什么同一个问题会有三个合法答案根源在于“随机取一条弦”这个描述本身是不完整的。在概率论里只有明确了概率分布随机事件才有意义。而“随机取一条弦”并没有指定弦是按端点分布、按中点分布还是按半径分布。这就好比你让人“随便在纸上画一条线”这个“随便”在每个人那里都有不同的执行方式。有人倾向于从纸的一端画到另一端有人倾向于画在中间还有人可能随手画一条短弧线。如果不定义清楚“随便”的规则讨论“画出的线有多长”的概率就没有统一答案。贝特朗悖论实际上是对“几何概率”的一次警示。在有限样本空间里‘等可能’这个概念是清楚的但在连续空间中“等可能”取决于你用什么参数来描述样本点。同样一条弦可以看成两个端点确定的也可以看成一个中点加一个方向确定的。不同的参数化方式会带来不同的“均匀分布”从而得到不同的概率。在 20 世纪概率论走向公理化用测度论严格定义了概率空间这个悖论才在理论上被彻底澄清。不过对于我们做可视化的人来说这个悖论反而是极好的教学素材它用一个非常具体的问题把“概率空间”这个抽象概念变成了肉眼可见的三张图。2. 三种作图方法的设计思路接下来进入正题也就是“作图”这件事。下面把三种方法从几何上、从代码实现上全部讲清楚。先理解每种方法的参数化方式再去实现代码这样写出来的图才不是一堆随机散点堆在一起。2.1 方法一固定端点法端点均匀分布第一种方法最直观也最接近人对“随机画弦”的直觉在圆周上随机选两个点连成一条线。代码实现时圆的参数方程非常简单。圆心设为原点半径为 R圆周上的点可以写成 (R·cos θ, R·sin θ)。随机均匀选两个角度 θ1、θ2对应的两个点就是弦的两个端点。几何推导也不难。固定其中一个点 A另一个点 B 在圆周上均匀移动。要使弦 AB 的长度大于 √3·RB 需要落在以 A 为顶点、对应圆心角为 120 度的等边三角形另一条弧段上。换一种等价说法A 和 B 之间的圆心角必须在 60 度到 300 度之间。圆周上一共有 360 度满足条件的区间长度是 240 度所以概率是 240/360 1/3。注意这里的关键在于“两个端点独立均匀分布”。如果你脑子里想的是“先在圆周上取一个点再随便选方向”那得到的分布已经变了。做图的时候要特别小心不能为了视觉效果随意改变采样方式。2.2 方法二随机半径法半径均匀分布第二种方法换了一个角度把弦看作由“中点 方向”决定。具体做法是先均匀随机选一个角度 θ代表一条从圆心出发的半径方向。在这条半径上均匀随机取一个位置 d也就是弦心距。然后过该点作垂直于半径的弦。这样得到的弦唯一地由 (d, θ) 确定。为什么这样采样会得到概率 1/2因为弦长 L 与弦心距 d 的关系是L 2·√(R² - d²)。要 L √3·R等价于 d R/2。由于 d 在 [0, R] 上均匀分布所以 d 落在 [0, R/2) 的概率正是 1/2。这个方法的几何含义非常清晰但实现时容易出一个小问题弦心距 d 必须是在“半径上均匀分布”而不是在圆内随机取一个点。很多人会把方法二和方法三搞混导致画出来的图四不像。后面实操部分会专门对比。2.3 方法三随机中点法中点均匀分布第三种方法看起来跟第二种很像但概率完全不同。它不再把中点限制在一条半径上而是允许中点在圆内任意位置均匀分布然后以该点为中点作弦。圆内均匀分布意味着一个点落在圆内任意小区域的概率与该小区域的面积成正比。这跟“半径上的均匀分布”有本质区别因为半径方向上的点在圆心附近更密集。它的概率推导同样用到弦心距条件 d R/2但此时 d 不是均匀分布的。中点到圆心的距离 d 的概率密度函数不再是常数而是正比于 2πd也就是半径越大对应的圆环面积越大。因此d R/2 的概率等于小圆面积与大圆面积之比π·(R/2)² / π·R² 1/4。这就是三种方法最核心的区别同样是“随机取弦的中点”一种是在半径上均匀一种是在圆面积上均匀。这个区别如果不画出来光看公式很难产生直观认知。作图的第一个价值就在这——把抽象的“概率分布差异”变成一眼能看出的疏密差异。2.4 三种方法的统一视角与参数对比把三种方法放到同一个坐标系里对比会更清楚方法随机变量几何判定条件理论概率固定端点法两个独立角度 θ1, θ2圆心角差的绝对值在 60°~300°1/3随机半径法角度 θ 半径位置 d弦心距 d R/21/2随机中点法圆内坐标 (x, y)弦心距 d R/21/4从表格里能发现一个有意思的规律固定端点法的角度条件跟另外两个方法不一样它依赖的是圆心角差而不是弦心距。实际上如果把固定端点法的弦心距推导出来它的中点分布既不是半径均匀也不是圆面积均匀而是一种更复杂的分布。这也是它得到第三组答案的根本原因。看清这个表格后再去看代码思路会清晰很多每种方法的采样逻辑不同代码里随机数的生成方式也不同。下面就开始实操把三种图画出来。3. 用 Python 实现三种作图与概率验证这一部分直接给出可复现代码。环境只需要 Python 3 加上 numpy 和 matplotlib这两个库是 Python 可视化的标配。先说一下整体结构再逐段拆解实现最后跑蒙特卡洛模拟验证概率。3.1 环境准备与整体代码框架在开始写代码之前先确认环境里装了必要的库。如果还没有安装可以在终端里执行下面的命令pip install numpy matplotlib接下来是代码结构设计。我在做可视化时习惯先把“画一个圆”和“画一条弦”封装成小函数再针对每种方法写一个采样函数最后用一个统一的绘图函数把三种方法并排展示。这样代码清晰也方便后续扩展成动画。下面先写公共部分import numpy as np import matplotlib.pyplot as plt # 公共常量 R 1.0 # 圆的半径 side np.sqrt(3) * R # 内接等边三角形边长 theta_grid np.linspace(0, 2 * np.pi, 200) def draw_circle(ax): 画一个以原点为圆心、半径为R的参考圆 ax.plot(R * np.cos(theta_grid), R * np.sin(theta_grid), colorgray, linewidth1.2, zorder1) ax.set_aspect(equal) ax.set_xlim(-1.2 * R, 1.2 * R) ax.set_ylim(-1.2 * R, 1.2 * R) ax.axhline(0, colorlightgray, linewidth0.8, linestyle--) ax.axvline(0, colorlightgray, linewidth0.8, linestyle--) def chord_length(p1, p2): 根据两个端点计算弦长 return np.hypot(p2[0] - p1[0], p2[1] - p1[1])这里的 chord_length 函数在后面的蒙特卡洛模拟里也会用到。把圆的绘制单独封装是为了避免在三种方法里重复粘贴画圆的代码。另外画布上加入浅灰色的参考十字线能让读者更容易判断弦与圆心的距离关系。3.2 固定端点法的实现与可视化固定端点法的核心是生成两个均匀随机角度。角度可以用np.random.uniform(0, 2 * np.pi)直接生成也可以先均匀生成 [0, 1) 再乘以 2π效果一样。下面是一个完成绘图流程的示例def sample_chord_endpoint_method(): 方法一圆上均匀取两个端点 theta1 np.random.uniform(0, 2 * np.pi) theta2 np.random.uniform(0, 2 * np.pi) p1 (R * np.cos(theta1), R * np.sin(theta1)) p2 (R * np.cos(theta2), R * np.sin(theta2)) return p1, p2 def plot_endpoint_method(ax, n_chords30, seed42): 绘制固定端点法的随机弦 rng np.random.default_rng(seed) draw_circle(ax) for _ in range(n_chords): theta1 rng.uniform(0, 2 * np.pi) theta2 rng.uniform(0, 2 * np.pi) p1 (R * np.cos(theta1), R * np.sin(theta1)) p2 (R * np.cos(theta2), R * np.sin(theta2)) L chord_length(p1, p2) color tab:red if L side else tab:blue ax.plot([p1[0], p2[0]], [p1[1], p2[1]], colorcolor, alpha0.7, linewidth1, zorder2) ax.scatter(*p1, colorblack, s8, zorder3) ax.scatter(*p2, colorblack, s8, zorder3) ax.set_title(fFixed Endpoint Method (p1/3), fontsize12)这段代码里我用default_rng(seed)固定了随机种子这样每次运行生成的图都一样方便对比三种方法。弦的颜色区分逻辑是弦长大于 √3·R 的用红色否则用蓝色。这样看图的时候能一眼看出“长弦”和“短弦”在空间中的分布差异。在实际操作中建议使用default_rng而不是旧式的np.random.seed。它在独立随机流、可重复性上更规范尤其在并行模拟时不容易串种子。3.3 随机半径法的实现与可视化随机半径法需要先生成一个方向角 θ再在这个方向上均匀采样弦心距 d最后算出弦的两个端点。已知中点坐标为 (d·cos θ, d·sin θ)法向单位向量是 (-sin θ, cos θ)。弦的两个端点的计算方式如下def chord_from_midpoint(mx, my, dir_angle): 给定弦中点(mx, my)和方向角返回弦的两个端点 # 方向角对应的法向单位向量即弦的方向 fx, fy np.cos(dir_angle), np.sin(dir_angle) # 弦长的一半 dist_to_center np.hypot(mx, my) half_len np.sqrt(R**2 - dist_to_center**2) p1 (mx half_len * fx, my half_len * fy) p2 (mx - half_len * fx, my - half_len * fy) return p1, p2注意这里的方向角不是半径方向而是与半径垂直的弦方向。如果直接用半径方向去算端点得到的点会落到圆外。这是一个很容易踩的坑后面在常见问题里会细说。完整的绘制函数如下def plot_radius_method(ax, n_chords30, seed42): rng np.random.default_rng(seed) draw_circle(ax) for _ in range(n_chords): theta rng.uniform(0, 2 * np.pi) d R * rng.uniform(0, 1) mx, my d * np.cos(theta), d * np.sin(theta) # 弦的方向垂直于半径方向 chord_dir theta np.pi / 2 p1, p2 chord_from_midpoint(mx, my, chord_dir) L chord_length(p1, p2) color tab:red if L side else tab:blue ax.plot([p1[0], p2[0]], [p1[1], p2[1]], colorcolor, alpha0.7, linewidth1, zorder2) ax.scatter(mx, my, colorblack, s6, zorder3) ax.set_title(fUniform Radius Method (p1/2), fontsize12)这样画出来的弦中点在半径上均匀分布。从图上看靠近圆心的弦数量少但每条弦更长靠近圆周的弦数量多但每条弦更短。不过要注意这里的“中点数量”并不是在面积上均匀的而是在每条半径线的线性距离上均匀的。3.4 随机中点法的实现与可视化随机中点法和上一个代码非常像差别只在一个地方中点坐标的采样方式。圆内均匀取点不是直接取r R * np.random.uniform(0, 1)而是要用平方根变换def random_uniform_point_in_circle(rng): 在半径为R的圆内均匀随机取一个点 u rng.uniform(0, 1) r R * np.sqrt(u) theta rng.uniform(0, 2 * np.pi) x r * np.cos(theta) y r * np.sin(theta) return x, y为什么要加np.sqrt因为极坐标下的面积微元是 r·dr·dθ。如果直接让半径 r 在 [0, R] 上均匀分布那么点的分布会偏向圆心——因为相同宽度的半径区间在圆心附近对应的面积更小。用sqrt(u)后累积分布函数才能保证每个微小面积区域被取到的概率相同。这一步是整个圆内均匀采样最常见的 bug 来源后面会专门讲。完整绘制函数如下def plot_midpoint_method(ax, n_chords30, seed42): rng np.random.default_rng(seed) draw_circle(ax) for _ in range(n_chords): mx, my random_uniform_point_in_circle(rng) # 弦的方向垂直于中点与圆心连线的方向 theta np.arctan2(my, mx) chord_dir theta np.pi / 2 p1, p2 chord_from_midpoint(mx, my, chord_dir) L chord_length(p1, p2) color tab:red if L side else tab:blue ax.plot([p1[0], p2[0]], [p1[1], p2[1]], colorcolor, alpha0.7, linewidth1, zorder2) ax.scatter(mx, my, colorblack, s6, zorder3) ax.set_title(fUniform Midpoint Method (p1/4), fontsize12)画完之后三种方法并排列在同一个小画布里视觉差异会非常明显端点法生成的弦方向多样长度两极分化明显半径法有大量短弦集中在外圈中点法则因为面积均匀大量中点落在半径较小的区域从而有更多红色长弦。为了更直观地对比可以加一行代码把三个子图放在一起fig, axes plt.subplots(1, 3, figsize(15, 5)) plot_endpoint_method(axes[0]) plot_radius_method(axes[1]) plot_midpoint_method(axes[2]) plt.tight_layout() plt.show()这是我个人比较推荐的呈现方式。一张图里放三种方法观众能立刻感受到“随机方式的不同”对结果有多大的影响。3.5 蒙特卡洛模拟验证三种概率画图只能看定性差异要定量验证概率还得靠蒙特卡洛模拟。模拟的逻辑很简单重复采样 N 次统计弦长大于 √3·R 的次数占比这个占比就是概率的估计值。def monte_carlo_probability(method, n_samples100000, seed1): rng np.random.default_rng(seed) count 0 for _ in range(n_samples): if method endpoint: theta1 rng.uniform(0, 2 * np.pi) theta2 rng.uniform(0, 2 * np.pi) p1 (R * np.cos(theta1), R * np.sin(theta1)) p2 (R * np.cos(theta2), R * np.sin(theta2)) elif method radius: theta rng.uniform(0, 2 * np.pi) d R * rng.uniform(0, 1) mx, my d * np.cos(theta), d * np.sin(theta) chord_dir theta np.pi / 2 p1, p2 chord_from_midpoint(mx, my, chord_dir) elif method midpoint: mx, my random_uniform_point_in_circle(rng) theta np.arctan2(my, mx) chord_dir theta np.pi / 2 p1, p2 chord_from_midpoint(mx, my, chord_dir) else: raise ValueError(fUnknown method: {method}) if chord_length(p1, p2) side: count 1 return count / n_samples for m in [endpoint, radius, midpoint]: prob monte_carlo_probability(m, n_samples200000) print(f{m:8s}: {prob:.4f})在我本机上跑 20 万次采样输出结果大概是endpoint : 0.3330 radius : 0.4994 midpoint : 0.2506三者跟理论值 1/3、1/2、1/4 都在千分位的误差范围内吻合。这里也体现了一个经验蒙特卡洛模拟的标准误差正比于 1/√N。20 万次采样下误差大约在 0.001 量级足够验证理论概率。4. 作图细节与可视化技巧图能不能让人一眼看懂比代码本身更考验功夫。这一部分说一下我在做贝特朗悖论可视化时积累的几个经验涉及配色、动画和过度绘制问题。4.1 配色与构图用颜色传递核心信息在贝特朗悖论的图里最重要的信息不是“弦的位置”而是“这条弦是长还是短”。因此我在代码里选用了红蓝双色区分红色代表弦长大于 √3·R蓝色代表小于。这两种颜色对比度高色盲用户也能通过明度差异分辨。另外网格和背景要尽量弱化不要让参考线和背景干扰弦的主体信息。我使用了浅灰色参考十字线和白色背景这样弦的线条是第一视觉层次圆是第二层次参考线是第三层次。有一点值得强调画三种方法时最好固定随机种子。如果不固定种子每次运行得到的弦都不一样读者会怀疑是不是不同的随机运气导致不同的概率。固定种子后三张图的比例关系是可复现的解释起来也更有说服力。4.2 从静态图到动画动态演示生成过程如果你想更进一步把随机取弦的过程做成动画效果会比静态图更震撼。动画的核心思路是每次迭代清空画布重新画一条或几条新弦。matplotlib 的FuncAnimation就能实现。我写过一个简化版本每帧生成一条新弦并保留之前画过的弦这样观众能直观看到弦的分布如何随着采样数量增加而逐渐显现形态。from matplotlib.animation import FuncAnimation def update(frame, method_type, ax, lines_holder): pass # 具体实现略核心是每帧调用采样函数并更新Line对象如果你只是做演示不一定要在动画里保留所有历史弦也可以每帧只画一条新弦让弦一闪而过。但经过我的反复对比保留历史弦并逐渐累积的效果更好——因为观众能看到分布密度从稀疏到稠密的变化过程这正是理解概率分布的关键。4.3 避免过度绘制弦多了图就糊了贝特朗悖论的图有一个天然问题如果需要展示大量弦线会重叠在一起导致图面非常杂乱。我在前面示例里每张子图画 30 条弦是经过斟酌的。如果你的目的是展示“大量随机弦的整体分布”建议把线条的透明度调到 0.2~0.3 左右同时把弦数量控制在 200~500 条。超过这个量级无论怎么调透明度图形都会变成一团色块信息反而丢失了。如果你做的是论文或博客配图我更推荐用 20~50 条弦加颜色区分因为人眼能直接从几十条线的排布中读出空间分布规律而不是靠统计颜色密度。透明度调低之后还有一个小技巧把满足条件的弦和不满足条件的弦分开用不同图层绘制先画短弦再画长弦或者反过来。这样即使弦的数量较多红蓝两组的层次关系也会比较清晰。5. 常见问题排查与避坑指南写这份代码的过程中有几个坑是我反复踩过的。如果你想把代码自己跑一遍这几个问题大概率也会遇到这里提前说出来。5.1 圆内均匀采样的 sqrt 陷阱圆内均匀采样这个环节几乎是所有初学者必踩的坑。如果直接用r R * np.random.uniform(0, 1)然后取x r * cos(theta)画出来的图看起来好像没什么问题但统计中点密度时会发现点大量集中在圆心附近。原因是极坐标的“面积微元”不是常数 dr·dθ而是 r·dr·dθ。必须在半径方向上用sqrt(u)进行变换才能让每个小面积区域被选中的概率相等。检查这个问题最直接的方法把随机点的位置画成散点图。如果圆心区域的点明显比外圈密那一定是忘了 sqrt。正确的结果应该是圆内每个小方格里的点数大致相等。5.2 弦端点坐标计算错误在随机半径法和随机中点法中弦端点的计算依赖一个很容易出错的方向判断。从弦中点向两侧延伸的向量必须垂直于“中点与圆心的连线”而不是平行于它。我见过不少人写代码时直接把(mx, my)当作方向向量结果算出的端点落在了圆外画出来的弦穿出圆面。正确做法是先算出圆心到中点的方向角 θ然后让弦的方向角为 θ π/2或者直接利用垂直向量的代数关系如果连线方向是 (mx, my)那么垂直方向是 (-my, mx)。写这段代码时我建议每次采样后都校验一下两个端点到圆心的距离是否约等于 R。如果误差超过 1e-9说明计算有误。这个简单的校验能在几秒内发现问题。5.3 模拟概率与理论值对不上怎么办如果你自己跑蒙特卡洛模拟发现概率跟 1/3、1/2、1/4 差得有点远先不要怀疑理论有问题。最常见的原因是样本量太小。标准误差的公式是 √(p(1-p)/N)。当 p 1/2 时用 1000 次采样算出的标准误差约 1.6 个百分点也就是说你看到的估计值可能在 0.48 到 0.52 之间浮动。要稳定到小数点后两位通常需要至少 10 万次采样。另一个容易忽略的原因是随机数生成器没有固定种子。如果你用不同种子跑了多次实验每次得到的结果都会有一个合理的浮动范围。这本身不是错误但如果只跑一次就拿着结果跟理论值对比往往会觉得“好像对不上”。我的习惯是先验证代码逻辑再跑 20 万次以上取稳定值最后用 bootstrap 方法估算置信区间这才算严谨。最后再说一个实际体会贝特朗悖论最让人惊讶的地方是它在理论上让人困惑但用图一画所有困惑都变成了“原来如此”。三种随机方式的视觉差异如此明显以至于你在看完图之后再回头看概率推导会觉得每一步都理所当然。这就是可视化在数学学习里的价值——它不能替你思考但它能把抽象的规则变成你眼里的直觉。如果你想把这段代码拿去讲给学生或同事听建议保留 30 条弦左右的版本先让他们猜概率再揭晓三种答案。这个互动过程比任何解释都更能让人记住。